Aufräumen, Reddit-Aufbereitung, Obsidian, Verlauf, iPhone-Kurzbefehl; Spenden vorerst aus

- clean.js: Webseiten aufräumen (Mozilla Readability für Artikel, sonst Navigation/Cookie-Banner/Knöpfe entfernen), Knopf 'Ohne Aufräumen'
- Reddit (neu und alt): Titel, Subreddit, Autor, keine Votes/Reply-Reste, Kommentare als verschachtelte Zitate mit (OP)
- Optionaler Knopf 'In Obsidian öffnen' (obsidian://new, Vault/Ordner einstellbar, Text über Zwischenablage)
- Optionaler Verlauf der letzten 10 Clips in IndexedDB, standardmäßig aus
- Anleitung iPhone-Kurzbefehl in der App und docs/ios-shortcut.md, ?from=ios weist auf Einfügen hin
- Spenden-Links ausgeblendet, bis ein funktionierender Link hinterlegt ist; PayPal-Abschnitt aus der Datenschutzerklärung entfernt
- Datenschutz um Verlauf und Obsidian ergänzt, SW-Cache v6
This commit is contained in:
till committed 2026-09-16 12:39:26 +02:00
1 parent 13eabc6247
commit b00633af0f
10 files changed
+3260 -52

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ FROM nginx:1.27-alpine
# Static single-page app — no build step.
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY security-headers.conf /etc/nginx/security-headers.conf
COPY index.html impressum.html datenschutz.html legal.css icon-mono.svg manifest.webmanifest sw.js icon.svg icon-192.png icon-512.png icon-maskable-512.png apple-touch-icon.png /usr/share/nginx/html/
COPY index.html clean.js impressum.html datenschutz.html legal.css icon-mono.svg manifest.webmanifest sw.js icon.svg icon-192.png icon-512.png icon-maskable-512.png apple-touch-icon.png /usr/share/nginx/html/
COPY vendor/ /usr/share/nginx/html/vendor/
EXPOSE 80
+13 -5
View File
@@ -25,6 +25,16 @@ Obsidian vault, a wiki, or a repo.
picks the HTML version when there is one. Browsers ask once for permission; `⌘V` always works too.
- **Smart paste.** Formatted HTML (copy from a browser, e.g. Reddit `⌘A` → `⌘C`) is converted to
clean Markdown. Plain text stays raw. Same `⌘V` for both, no mode switch.
- **Page clean-up.** Pasting a whole web page drops navigation, cookie banners, share buttons and
sidebars; articles are extracted with Mozilla Readability. *Undo clean-up* re-inserts the
unfiltered version with one click.
- **Reddit threads.** New and old Reddit: post title, subreddit and author on top, no vote counts or
Reply/Share leftovers, comments as nested quotes with `(OP)` marked.
- **Open in Obsidian.** Optional button that creates the note straight in your vault (vault and
folder configurable), via the `obsidian://` URI and the clipboard — no file detour.
- **History (opt-in).** Keeps the last 10 clips in this browser's IndexedDB. Off by default.
- **iPhone shortcut.** iOS keeps web apps out of the share sheet; a four-step Shortcut fills the gap
(see [docs/ios-shortcut.md](docs/ios-shortcut.md) or *iPhone shortcut* in the app footer).
- **Force raw.** `⌘/Ctrl+Shift+V` pastes the next clip as plain text, skipping conversion.
- **Live preview.** Toggle a side-by-side rendered Markdown view (marked + DOMPurify).
- **Auto filename.** Derived from the first heading + date, e.g. `2026-08-11-my-thread.md`.
@@ -79,11 +89,12 @@ Any static host works — point it at this repo. The reference deployment uses
## Update vendored libraries
```bash
npm pack turndown turndown-plugin-gfm marked dompurify @fontsource/space-grotesk @fontsource/space-mono
npm pack turndown turndown-plugin-gfm marked dompurify @mozilla/readability @fontsource/space-grotesk @fontsource/space-mono
# turndown/dist/turndown.js -> vendor/turndown.js
# turndown-plugin-gfm/dist/turndown-plugin-gfm.js -> vendor/turndown-plugin-gfm.js
# marked/lib/marked.umd.js -> vendor/marked.umd.js
# dompurify/dist/purify.min.js -> vendor/purify.min.js
# @mozilla/readability/Readability.js -> vendor/Readability.js
# @fontsource/*/files/*-latin-{400,500,700}-normal.woff2 -> vendor/fonts/
```
@@ -91,8 +102,5 @@ Bump the `CACHE` constant in `sw.js` whenever an asset changes so clients pick i
## License
[MIT](LICENSE). Turndown, marked, DOMPurify, Space Grotesk and Space Mono are under their respective MIT/Apache/OFL licenses.
[MIT](LICENSE). Turndown, marked, DOMPurify, Mozilla Readability, Space Grotesk and Space Mono are under their respective MIT/Apache/OFL licenses.
## Support
clip2md is free. If it saves you time: [buy me a coffee via PayPal](https://www.paypal.com/donate/?business=info%40tillheidrich.de&currency_code=EUR&item_name=clip2md).
+159
View File
@@ -0,0 +1,159 @@
/* clip2md — page clean-up and Reddit formatting. Runs entirely in the browser.
Exposes window.Clip2mdClean = { prepare, postprocess }. */
(function () {
'use strict';
var BUTTON_WORDS = /^(reply|share|report|save|saved|follow|following|subscribe|unsubscribe|join|joined|award|give award|upvote|downvote|vote|like|likes|comment|comments|copy link|copy text|embed|print|tweet|pin it|email|more|more options|show more|show less|read more|see more|continue reading|load more|view more comments|back to top|skip to content|skip to main content|accept|accept all|reject|reject all|allow all|decline|manage cookies|cookie settings|close|dismiss|sign in|log in|sign up|antworten|teilen|melden|speichern|folgen|abonnieren|beitreten|gefällt mir|kommentieren|link kopieren|drucken|mehr|mehr anzeigen|weniger anzeigen|weiterlesen|mehr laden|nach oben|zum inhalt springen|akzeptieren|alle akzeptieren|ablehnen|alle ablehnen|einstellungen|cookie-einstellungen|schließen|anmelden|registrieren)$/i;
var JUNK_ATTR = /(^|[\s_-])(cookie|consent|gdpr|cmp|banner|newsletter|subscribe|share|sharing|social|advert|ad-slot|ads|sponsor|promo|popup|modal|overlay|sidebar|breadcrumb|related|recommend|paywall|toolbar|skip-link|comment-form|site-header|site-footer|masthead)([\s_-]|$)/i;
var JUNK_SELECTORS = 'nav, aside, footer, form, button, input, select, textarea, dialog, iframe, noscript, template, [hidden], [aria-hidden="true"], [role="navigation"], [role="banner"], [role="contentinfo"], [role="dialog"], [role="alertdialog"], [role="complementary"], [role="search"], [role="menu"], [role="menubar"], [role="toolbar"]';
function textLen(el) { return el ? (el.textContent || '').replace(/\s+/g, ' ').trim().length : 0; }
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
// ---------- Generic page clean-up ----------
function stripJunk(root) {
var total = textLen(root) || 1, removed = 0;
Array.prototype.slice.call(root.querySelectorAll(JUNK_SELECTORS)).forEach(function (el) {
// Never drop the bulk of the content just because it sits in an odd wrapper.
if (el.isConnected && textLen(el) < total * 0.5) { el.remove(); removed++; }
});
Array.prototype.slice.call(root.querySelectorAll('[class], [id]')).forEach(function (el) {
if (!el.isConnected || /^(html|body|main|article)$/i.test(el.tagName)) return;
var sig = (el.getAttribute('class') || '') + ' ' + (el.id || '');
if (JUNK_ATTR.test(sig) && textLen(el) < total * 0.25) { el.remove(); removed++; }
});
Array.prototype.slice.call(root.querySelectorAll('a, span, div, li, p, strong, b')).forEach(function (el) {
if (!el.isConnected) return;
var t = (el.textContent || '').replace(/\s+/g, ' ').trim();
if (t && t.length < 32 && BUTTON_WORDS.test(t) && !el.querySelector('img')) { el.remove(); removed++; }
});
Array.prototype.slice.call(root.querySelectorAll('img')).forEach(function (img) {
var w = parseInt(img.getAttribute('width'), 10), h = parseInt(img.getAttribute('height'), 10);
var alt = (img.getAttribute('alt') || '') + ' ' + (img.getAttribute('src') || '');
if ((w && w <= 48) || (h && h <= 48) || /avatar|icon|logo|emoji|spacer|pixel|tracking/i.test(alt)) { img.remove(); removed++; }
});
return removed;
}
function looksLikePage(doc) {
return !!doc.querySelector('article, main, [role="main"], nav, header, footer') || doc.querySelectorAll('a').length > 25;
}
// ---------- Reddit ----------
function isReddit(doc, html) {
if (doc.querySelector('shreddit-comment, shreddit-post, .thing.comment, .thing.link, [data-testid="comment"]')) return true;
return /reddit\.com\/(r|user)\//i.test(html) && /\b(reply|upvote|antworten)\b/i.test(doc.body ? doc.body.textContent : '');
}
function quote(lines, depth) {
var p = new Array(depth + 1).join('> ');
return lines.map(function (l) { return (p + l).replace(/\s+$/, ''); });
}
function nearest(el, sel) { var p = el.parentElement; return p ? p.closest(sel) : null; }
function redditToMd(doc, toMd, labels) {
var out = [], post, title, author, sub, body, comments = [];
// New Reddit (shreddit web components)
post = doc.querySelector('shreddit-post');
var sc = doc.querySelectorAll('shreddit-comment');
if (post || sc.length) {
title = post && post.getAttribute('post-title');
author = post && post.getAttribute('author');
sub = post && post.getAttribute('subreddit-prefixed-name');
body = post && post.querySelector('[slot="text-body"]');
var top = Array.prototype.filter.call(sc, function (c) { return !nearest(c, 'shreddit-comment'); });
var renderNew = function (c, depth) {
var a = c.getAttribute('author') || '[deleted]';
var bodyEl = Array.prototype.filter.call(c.querySelectorAll('[slot="comment"]'), function (b) { return b.closest('shreddit-comment') === c; })[0];
var lines = ['**u/' + a + '**' + (author && a === author ? ' (OP)' : ''), ''];
lines = lines.concat((bodyEl ? toMd(bodyEl.innerHTML) : '').split('\n'));
var res = quote(lines, depth);
Array.prototype.filter.call(c.querySelectorAll('shreddit-comment'), function (k) { return nearest(k, 'shreddit-comment') === c; })
.forEach(function (k) { res.push(quote([''], depth)[0]); res = res.concat(renderNew(k, depth + 1)); });
return res;
};
top.forEach(function (c) { comments.push(renderNew(c, 1).join('\n')); });
} else {
// Old Reddit
var link = doc.querySelector('.thing.link');
var oc = doc.querySelectorAll('.thing.comment');
if (!link && !oc.length) return null;
if (link) {
var ta = link.querySelector('a.title'); title = ta && ta.textContent.trim();
var au = link.querySelector('.tagline .author'); author = au && au.textContent.trim();
var sr = link.querySelector('.tagline .subreddit'); sub = sr && sr.textContent.trim();
body = link.querySelector('.usertext-body .md');
}
var topOld = Array.prototype.filter.call(oc, function (c) { return !nearest(c, '.thing.comment'); });
var renderOld = function (c, depth) {
var entry = c.querySelector(':scope > .entry') || c;
var au2 = entry.querySelector('.tagline .author'); var a = au2 ? au2.textContent.trim() : '[deleted]';
var md = entry.querySelector('.usertext-body .md');
var lines = ['**u/' + a + '**' + (author && a === author ? ' (OP)' : ''), ''].concat((md ? toMd(md.innerHTML) : '').split('\n'));
var res = quote(lines, depth);
Array.prototype.filter.call(c.querySelectorAll('.thing.comment'), function (k) { return nearest(k, '.thing.comment') === c; })
.forEach(function (k) { res.push(quote([''], depth)[0]); res = res.concat(renderOld(k, depth + 1)); });
return res;
};
topOld.forEach(function (c) { comments.push(renderOld(c, 1).join('\n')); });
}
if (!title) { var h1 = doc.querySelector('h1'); title = h1 && h1.textContent.trim(); }
if (title) out.push('# ' + title, '');
var meta = [sub, author ? 'u/' + author : ''].filter(Boolean).join(' · ');
if (meta) out.push('*' + meta + '*', '');
if (body) { var b = toMd(body.innerHTML); if (b) out.push(b, ''); }
if (comments.length) { out.push('---', '', '## ' + labels.comments + ' (' + comments.length + ')', '', comments.join('\n\n')); }
var res = out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
return res.length > 20 ? res : null;
}
var REDDIT_LINE = /^(reply|share|report|save|follow|award|upvote|downvote|vote|more replies|\d+ more repl(y|ies)|level \d+|op|edited|•|·|u\/[\w-]+ avatar|r\/[\w-]+ icon|\d+(\.\d+)?\s*[kKmM]?|\d+\s*(s|m|h|d|w|mo|y|yr|min|hr)\.?\s*ago|go to comments|sort by:?|best|top|new|controversial|old|q&a|comments|antworten|teilen|melden)$/i;
function postprocess(md, opts) {
var lines = md.split('\n'), out = [], inFence = false;
for (var i = 0; i < lines.length; i++) {
var l = lines[i], t = l.trim();
if (/^(```|~~~)/.test(t)) { inFence = !inFence; out.push(l); continue; }
if (inFence) { out.push(l); continue; }
var bare = t.replace(/^[-*+]\s+/, '').replace(/^\[([^\]]*)\]\([^)]*\)$/, '$1').trim();
if (bare && BUTTON_WORDS.test(bare)) continue;
if (opts.reddit && bare && REDDIT_LINE.test(bare)) continue;
if (/^\[\s*\]\([^)]*\)$/.test(t) || /^[|•·\-–—]+$/.test(t) && !/^-{3,}$/.test(t)) continue;
out.push(l.replace(/\[\s*\]\([^)]*\)/g, ''));
}
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
/* prepare(html, opts) → { md?, html?, kind, title? }
opts: { cleanup, reddit, toMd(htmlString)→md, labels:{comments} } */
function prepare(html, opts) {
var doc;
try { doc = new DOMParser().parseFromString(html, 'text/html'); } catch (e) { return { html: html, kind: 'plain' }; }
if (!doc || !doc.body) return { html: html, kind: 'plain' };
var reddit = isReddit(doc, html);
if (opts.reddit && reddit) {
try { var rmd = redditToMd(doc, opts.toMd, opts.labels); if (rmd) return { md: rmd, kind: 'reddit', reddit: true }; } catch (e) {}
}
if (!opts.cleanup) return { html: html, kind: 'plain', reddit: reddit };
var before = textLen(doc.body);
if (before > 1200 && typeof window.Readability === 'function' && looksLikePage(doc) && !reddit) {
try {
var art = new window.Readability(doc.cloneNode(true), { charThreshold: 250, keepClasses: false }).parse();
if (art && art.content && (art.textContent || '').trim().length > before * 0.3) {
var content = art.content;
if (art.title && content.indexOf(art.title) < 0) content = '<h1>' + esc(art.title) + '</h1>' + content;
return { html: content, kind: 'article', title: art.title, reddit: reddit };
}
} catch (e) {}
}
var n = stripJunk(doc.body);
return { html: doc.body.innerHTML, kind: n ? 'cleaned' : 'plain', reddit: reddit };
}
window.Clip2mdClean = { prepare: prepare, postprocess: postprocess };
})();
+16 -25
View File
@@ -64,6 +64,12 @@
Auf Smartphones übergibt „Speichern" die Datei an das Teilen-Menü Ihres Betriebssystems; wohin
Sie sie von dort weitergeben, entscheiden Sie selbst.
</p>
<p>
Das Aufräumen eingefügter Webseiten und die Aufbereitung von Reddit-Threads laufen ebenfalls
vollständig in Ihrem Browser. Der optionale Knopf „In Obsidian öffnen" legt den Text in die
Zwischenablage und übergibt ihn an die Obsidian-App auf Ihrem Gerät; an uns oder Dritte wird
dabei nichts übertragen.
</p>
<h2>3. Lokale Speicherung Ihrer Einstellungen</h2>
<p>
@@ -74,6 +80,13 @@
eingefügten Inhalte. Es handelt sich nicht um Cookies. Sie können die Werte jederzeit über
die Einstellungen Ihres Browsers löschen.
</p>
<p>
Nur wenn Sie in den Einstellungen „Letzte 10 Clips behalten" einschalten, speichert die Seite
die zuletzt bearbeiteten Texte in der lokalen Datenbank Ihres Browsers (<span class="mono">IndexedDB</span>).
Die Funktion ist standardmäßig ausgeschaltet. Die Einträge verlassen Ihr Gerät nicht, sind nur in
diesem Browser sichtbar und lassen sich im Verlauf einzeln oder vollständig löschen; beim
Ausschalten werden Sie gefragt, ob der Verlauf gelöscht werden soll.
</p>
<p>
Rechtsgrundlage für diese ausschließlich lokale Speicherung ist Ihr Wunsch nach einer
funktionsfähigen Bedienoberfläche; ein Zugriff unsererseits findet nicht statt.
@@ -132,28 +145,7 @@
vermeiden möchten, lassen Sie die Vorschau ausgeschaltet.
</p>
<h2>8. Spenden über PayPal</h2>
<p>
Auf der Seite finden Sie einen Link „Spenden" bzw. „Buy me a coffee". Es handelt sich um einen
einfachen Link; beim Aufruf von clip2md werden keine Inhalte, Skripte oder Cookies von PayPal
geladen und keine Daten an PayPal übertragen. Erst wenn Sie den Link anklicken, verlassen Sie
diese Seite und werden zu PayPal weitergeleitet.
</p>
<p>
Anbieter ist die PayPal (Europe) S.à r.l. et Cie, S.C.A., 22–24 Boulevard Royal, L-2449
Luxemburg. Für die Verarbeitung auf den Seiten von PayPal ist PayPal selbst verantwortlich; es
gilt dessen Datenschutzerklärung:
<a href="https://www.paypal.com/de/legalhub/privacy-full" rel="noopener nofollow">paypal.com/de/legalhub/privacy-full</a>.
</p>
<p>
Wenn Sie spenden, erhalten wir von PayPal die zur Abwicklung nötigen Angaben: Ihren Namen, Ihre
E-Mail-Adresse, den Betrag, das Datum und gegebenenfalls eine Nachricht, die Sie hinzufügen.
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Abwicklung der Zuwendung) sowie Art. 6 Abs. 1
lit. c DSGVO in Verbindung mit den steuer- und handelsrechtlichen Aufbewahrungspflichten
(in der Regel zehn Jahre). Eine weitere Nutzung, etwa für Werbung, findet nicht statt.
</p>
<h2>9. Ihre Rechte</h2>
<h2>8. Ihre Rechte</h2>
<p>
Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung
(Art. 17), Einschränkung der Verarbeitung (Art. 18), Datenübertragbarkeit (Art. 20) sowie
@@ -161,20 +153,19 @@
(Art. 21). Wenden Sie sich dafür an die oben genannte Adresse.
</p>
<h2>10. Beschwerderecht</h2>
<h2>9. Beschwerderecht</h2>
<p>
Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren. Zuständig ist die
Landesbeauftragte für den Datenschutz Niedersachsen, Prinzenstraße 5, 30159 Hannover.
</p>
<h2>11. Stand</h2>
<h2>10. Stand</h2>
<p>Diese Datenschutzerklärung hat den Stand September 2026.</p>
</div>
<div class="legal-foot">
<a href="/">← Zurück zu clip2md</a>
<a href="/impressum.html">Impressum</a>
<a href="https://www.paypal.com/donate/?business=info%40tillheidrich.de&amp;currency_code=EUR&amp;item_name=clip2md" target="_blank" rel="noopener nofollow">Spenden</a>
</div>
</main>
</body>
+25
View File
@@ -0,0 +1,25 @@
# iPhone shortcut for clip2md
iOS does not let web apps into the share sheet. This Shortcut copies the current Safari page and opens clip2md.
1. Open **Shortcuts**, tap **+**, name it **clip2md**.
2. Tap **ⓘ**, enable **Show in Share Sheet**, allow **Safari web pages**, **Rich text**, **Text**.
3. Add **Get Details of Safari Web Page** → **Page Contents** (input: Shortcut Input).
4. Add **Copy to Clipboard**.
5. Add **Open URLs** → `https://clip2md.heidrich-digital.de/?from=ios` (use your own domain when self-hosting).
In Safari: Share → **clip2md** → tap **Paste**. iOS asks once whether clip2md may paste.
For a selection only, no Shortcut is needed: copy it and use the Paste button.
---
## Deutsch
1. App **Kurzbefehle** öffnen, **+** tippen, Name **clip2md**.
2. **ⓘ** tippen, **Im Share-Sheet anzeigen** einschalten, **Safari-Webseiten**, **Rich Text**, **Text** zulassen.
3. Aktion **Details von Safari-Webseite abrufen** → **Seiteninhalt**.
4. Aktion **In Zwischenablage kopieren**.
5. Aktion **URLs öffnen** → `https://clip2md.heidrich-digital.de/?from=ios`
In Safari: Teilen → **clip2md** → **Einfügen** tippen.
+1 -2
View File
@@ -65,7 +65,7 @@
<h2>Urheberrecht und Lizenz</h2>
<p>
Der Quellcode von clip2md steht unter der MIT-Lizenz. Die eingebundenen Bibliotheken
(Turndown, Marked, DOMPurify, MIT- bzw. Apache-Lizenz) und die Schriftfamilien Space Grotesk und Space Mono stehen unter ihren jeweiligen
(Turndown, Marked, DOMPurify, Mozilla Readability; MIT- bzw. Apache-Lizenz) und die Schriftfamilien Space Grotesk und Space Mono stehen unter ihren jeweiligen
freien Lizenzen (Schriften: SIL Open Font License). Texte und Gestaltung dieser Seite unterliegen dem deutschen Urheberrecht.
</p>
@@ -73,7 +73,6 @@
<div class="legal-foot">
<a href="/">← Zurück zu clip2md</a>
<a href="/datenschutz.html">Datenschutzerklärung</a>
<a href="https://www.paypal.com/donate/?business=info%40tillheidrich.de&amp;currency_code=EUR&amp;item_name=clip2md" target="_blank" rel="noopener nofollow">Spenden</a>
</div>
</main>
</body>
+243 -18
View File
@@ -111,6 +111,14 @@
.pill.donate { color: var(--accent); border-color: color-mix(in oklab, var(--accent) 45%, var(--line)); }
.pill.donate:hover { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.pill.donate svg { width: 16px; height: 16px; }
.mini { min-height: 30px; padding: 0 11px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 999px;
background: transparent; color: var(--ink-soft); font-size: 0.8rem; font-weight: 500; cursor: pointer; white-space: nowrap; transition: color .15s, background .15s, border-color .15s; }
.mini:hover { color: var(--ink); background: var(--tint); }
.mini.accent { color: var(--accent); border-color: color-mix(in oklab, var(--accent) 40%, var(--line)); }
.mini svg { width: 14px; height: 14px; }
.mini[hidden] { display: none; }
.btn.nudge { animation: nudge 1.4s var(--ease) 3; }
@keyframes nudge { 0%,100% { box-shadow: 0 0 0 0 var(--accent-bg); } 50% { box-shadow: 0 0 0 8px color-mix(in oklab, var(--accent) 22%, transparent); } }
/* ---------- Action bar ---------- */
.bar { display: flex; align-items: stretch; gap: 8px; padding: 16px var(--gut) 12px; flex-wrap: wrap; }
@@ -156,6 +164,7 @@
.pane-head .regmark::before { left: 50%; top: 0; width: 1.5px; height: 100%; transform: translateX(-50%); }
.pane-head .regmark::after { top: 50%; left: 0; height: 1.5px; width: 100%; transform: translateY(-50%); }
.pane-head .spacer { flex: 1; }
.pane-head .badge { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
main:not(.split) .preview-pane { display: none; }
.editor-pane:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
main.drag .editor-pane { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
@@ -206,6 +215,29 @@
footer nav a { color: var(--ink-soft); text-decoration: none; padding: 6px 0; }
footer nav a:hover { color: var(--accent); }
footer nav a.coffee { color: var(--accent); font-weight: 500; }
footer nav button.linkish { background: none; border: 0; padding: 6px 0; color: var(--ink-soft); cursor: pointer; font-size: inherit; }
footer nav button.linkish:hover { color: var(--accent); }
[data-donate][hidden] { display: none !important; }
/* History + help sheets reuse .pop */
.pop.wide { width: 420px; }
.hist { list-style: none; margin: 0; padding: 0 6px 6px; }
.hist li { display: flex; align-items: center; gap: 8px; border-radius: var(--radius-sm); }
.hist li:hover { background: var(--tint); }
.hist .load { flex: 1; min-width: 0; text-align: left; background: none; border: 0; padding: 10px 8px; cursor: pointer; min-height: 48px; }
.hist .load b { display: block; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hist .load span { font-family: var(--mono); font-size: 11px; color: var(--ink-soft); }
.hist .del { width: 40px; height: 40px; border: 0; background: none; color: var(--ink-soft); cursor: pointer; border-radius: 999px; }
.hist .del:hover { color: var(--err); }
.pop .empty-note { padding: 6px 12px 14px; color: var(--ink-soft); font-size: 0.9rem; margin: 0; }
.pop .foot { display: flex; justify-content: space-between; gap: 8px; padding: 6px 12px 10px; }
.help { padding: 0 12px 12px; font-size: 0.93rem; }
.help ol { padding-left: 1.2rem; margin: 6px 0 10px; }
.help li { margin-bottom: 6px; }
.help code { font-family: var(--mono); font-size: 0.82em; background: var(--tint); padding: 1px 4px; border-radius: 3px; overflow-wrap: anywhere; }
.help p { margin: 6px 0; color: var(--ink-soft); }
.sheet-head { display: flex; align-items: center; justify-content: space-between; padding: 6px 6px 0 12px; }
.sheet-head h2 { font-size: 1.05rem; margin: 6px 0; }
/* ---------- Settings sheet ---------- */
.scrim { position: fixed; inset: 0; z-index: 45; background: oklch(21% 0.01 95 / 0.25); opacity: 0; pointer-events: none; transition: opacity .2s var(--ease); }
@@ -270,6 +302,11 @@
.pop.open { animation: sheet .24s var(--ease); }
@keyframes sheet { from { transform: translateY(40px); opacity: 0; } to { transform: none; opacity: 1; } }
footer { font-size: 0.8rem; }
.pop.wide { width: auto; }
.pane-head { flex-wrap: wrap; row-gap: 6px; }
#histBtn span, #obsBtn span { display: none; }
#histBtn, #obsBtn { width: 34px; padding: 0; justify-content: center; }
.mini { min-height: 34px; }
}
@media (max-width: 380px) {
.brand { font-size: 1.05rem; }
@@ -302,7 +339,7 @@
<svg class="i" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z"/></svg>
</button>
<button id="langBtn" class="pill lang" title="Deutsch">DE</button>
<a class="pill donate" id="donateTop" href="https://www.paypal.com/donate/?business=info%40tillheidrich.de&amp;currency_code=EUR&amp;item_name=clip2md" target="_blank" rel="noopener nofollow" data-i18n-title="t_donate" title="Support clip2md">
<a class="pill donate" id="donateTop" data-donate hidden href="#" target="_blank" rel="noopener nofollow" data-i18n-title="t_donate" title="Support clip2md">
<svg class="i" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20s-7.5-4.6-7.5-10.2A4.3 4.3 0 0 1 12 7.2a4.3 4.3 0 0 1 7.5 2.6C19.5 15.4 12 20 12 20Z"/></svg>
<span class="lbl" data-i18n="donate">Donate</span>
</a>
@@ -347,6 +384,15 @@
<span class="regmark" aria-hidden="true"></span><span class="mono-label">Markdown</span>
<span class="spacer"></span>
<span class="badge mono-label" id="srcBadge" aria-live="polite"><span class="led"></span><span id="srcText"></span></span>
<button class="mini" id="rawBtn" type="button" hidden data-i18n="raw_again">Undo clean-up</button>
<button class="mini" id="histBtn" type="button" hidden data-i18n-title="history" title="History">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.5-6"/><path d="M3.5 4.5V9H8"/><path d="M12 7.5V12l3 2"/></svg>
<span data-i18n="history">History</span>
</button>
<button class="mini accent" id="obsBtn" type="button" hidden data-i18n-title="t_obs" title="Open in Obsidian">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.8 19 7v10l-7 4.2L5 17V7Z"/><path d="M12 2.8v18.4"/></svg>
<span>Obsidian</span>
</button>
</div>
<textarea id="editor" spellcheck="false" autocapitalize="off" autocorrect="off" aria-label="Markdown editor"></textarea>
<div class="empty" id="empty">
@@ -371,7 +417,8 @@
<span data-i18n="privacy">Runs in your browser. Nothing is uploaded.</span>
<span class="spacer"></span>
<nav aria-label="Footer">
<a class="coffee" href="https://www.paypal.com/donate/?business=info%40tillheidrich.de&amp;currency_code=EUR&amp;item_name=clip2md" target="_blank" rel="noopener nofollow" data-i18n="coffee">Buy me a coffee</a>
<a class="coffee" data-donate hidden href="#" target="_blank" rel="noopener nofollow" data-i18n="coffee">Buy me a coffee</a>
<button class="linkish" id="helpBtn" type="button" data-i18n="iphone">iPhone shortcut</button>
<a href="https://github.com/tillheidrich/clip2md" target="_blank" rel="noopener">GitHub</a>
<a href="/impressum.html">Impressum</a>
<a href="/datenschutz.html">Datenschutz</a>
@@ -411,6 +458,32 @@
</select>
</div>
<div class="sep"></div>
<h3 class="mono-label" data-i18n="s_clean">Clean-up</h3>
<div class="row">
<label for="cleanToggle"><span data-i18n="s_cleanup">Clean up web pages</span><span class="hint" data-i18n="s_cleanup_hint">Drop navigation, cookie banners, buttons</span></label>
<span class="sw"><input type="checkbox" id="cleanToggle" /><span class="track"></span><span class="knob"></span></span>
</div>
<div class="row">
<label for="redditToggle"><span data-i18n="s_reddit">Format Reddit threads</span><span class="hint" data-i18n="s_reddit_hint">No vote counts, comments as nested quotes</span></label>
<span class="sw"><input type="checkbox" id="redditToggle" /><span class="track"></span><span class="knob"></span></span>
</div>
<div class="sep"></div>
<h3 class="mono-label">Obsidian</h3>
<div class="row">
<label for="obsToggle"><span data-i18n="s_obs">“Open in Obsidian” button</span><span class="hint" data-i18n="s_obs_hint">Creates the note directly in your vault</span></label>
<span class="sw"><input type="checkbox" id="obsToggle" /><span class="track"></span><span class="knob"></span></span>
</div>
<div class="field two" id="obsFields" hidden>
<input id="obsVault" type="text" autocapitalize="off" autocorrect="off" spellcheck="false" data-i18n-ph="ph_vault" placeholder="Vault (optional)" />
<input id="obsFolder" type="text" autocapitalize="off" autocorrect="off" spellcheck="false" data-i18n-ph="ph_folder" placeholder="Folder (optional)" />
</div>
<div class="sep"></div>
<h3 class="mono-label" data-i18n="history">History</h3>
<div class="row">
<label for="histToggle"><span data-i18n="s_hist">Keep the last 10 clips</span><span class="hint" data-i18n="s_hist_hint">Stored only in this browser. Off by default.</span></label>
<span class="sw"><input type="checkbox" id="histToggle" /><span class="track"></span><span class="knob"></span></span>
</div>
<div class="sep"></div>
<h3 class="mono-label" data-i18n="s_file">Filename</h3>
<div class="field">
<select id="namePattern" aria-label="Filename pattern">
@@ -420,12 +493,25 @@
</div>
</div>
<div class="pop wide" id="histPop" role="dialog" aria-labelledby="histTitle">
<div class="sheet-head"><h2 id="histTitle" data-i18n="history">History</h2><button class="pill icon" type="button" data-close aria-label="Close">✕</button></div>
<ul class="hist" id="histList"></ul>
<p class="empty-note" id="histEmpty" data-i18n="hist_empty">No clips saved yet.</p>
<div class="foot"><span class="mono-label" data-i18n="hist_local">Only in this browser</span><button class="mini" type="button" id="histClear" data-i18n="hist_clear">Delete all</button></div>
</div>
<div class="pop wide" id="helpPop" role="dialog" aria-labelledby="helpTitle">
<div class="sheet-head"><h2 id="helpTitle" data-i18n="iphone">iPhone shortcut</h2><button class="pill icon" type="button" data-close aria-label="Close">✕</button></div>
<div class="help" id="helpBody"></div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="vendor/turndown.js"></script>
<script src="vendor/turndown-plugin-gfm.js"></script>
<script src="vendor/marked.umd.js"></script>
<script src="vendor/purify.min.js"></script>
<script src="vendor/Readability.js"></script>
<script src="clean.js"></script>
<script>
(function () {
"use strict";
@@ -433,7 +519,18 @@
var T = {
en: {
tagline: 'Paste → Markdown', preview: 'Preview', file: 'File', save: 'Save', copy: 'Copy', clear: 'Clear', open: 'Open', paste: 'Paste',
donate: 'Donate', coffee: 'Buy me a coffee', t_donate: 'Support clip2md via PayPal',
donate: 'Donate', coffee: 'Buy me a coffee', t_donate: 'Support clip2md',
raw_again: 'Undo clean-up', t_obs: 'Open in Obsidian', history: 'History', iphone: 'iPhone shortcut',
src_article: 'Article extracted', src_cleaned: 'Page cleaned up', src_reddit: 'Reddit thread',
s_clean: 'Clean-up', s_cleanup: 'Clean up web pages', s_cleanup_hint: 'Drop navigation, cookie banners, buttons',
s_reddit: 'Format Reddit threads', s_reddit_hint: 'No vote counts, comments as nested quotes',
s_obs: '“Open in Obsidian” button', s_obs_hint: 'Creates the note directly in your vault', ph_vault: 'Vault (optional)', ph_folder: 'Folder (optional)',
s_hist: 'Keep the last 10 clips', s_hist_hint: 'Stored only in this browser. Off by default.',
hist_empty: 'No clips saved yet.', hist_local: 'Only in this browser', hist_clear: 'Delete all', hist_clear_confirm: 'Delete the whole history?',
hist_off_confirm: 'Also delete the clips saved so far?', hist_loaded: 'Clip restored', comments: 'Comments',
obs_open: 'Opening Obsidian …', obs_fail: 'Could not hand over to Obsidian — copy the Markdown instead', raw_done: 'Inserted without clean-up',
ios_hint: 'Tap “Paste” to take over the page',
help_html: '<p>iOS does not let web apps into the share sheet. A shortcut does the job: it copies the page and opens clip2md.</p><ol><li>Open the <b>Shortcuts</b> app, tap <b>+</b>, name it <b>clip2md</b>.</li><li>Tap the <b>ⓘ</b> icon, turn on <b>Show in Share Sheet</b> and allow <b>Safari web pages</b>, <b>Rich text</b>, <b>Text</b>.</li><li>Add <b>Get Details of Safari Web Page</b>, set it to <b>Page Contents</b>.</li><li>Add <b>Copy to Clipboard</b>.</li><li>Add <b>Open URLs</b> with <code>https://clip2md.heidrich-digital.de/?from=ios</code></li></ol><p>In Safari: Share → <b>clip2md</b> → tap <b>Paste</b>. The first time, iOS asks whether clip2md may paste.</p><p>For a selection only: copy it, then use the Paste button — no shortcut needed.</p>',
empty_h: 'Paste to start',
empty_p: 'A long Reddit thread, an article, any formatted text. Rich HTML becomes clean Markdown, plain text stays as it is.',
paste_big: 'Paste from clipboard', k_paste: 'paste', k_save: 'save', k_drop: 'or drop a file',
@@ -452,7 +549,18 @@
},
de: {
tagline: 'Einfügen → Markdown', preview: 'Vorschau', file: 'Datei', save: 'Speichern', copy: 'Kopieren', clear: 'Leeren', open: 'Öffnen', paste: 'Einfügen',
donate: 'Spenden', coffee: 'Spendier mir einen Kaffee', t_donate: 'clip2md per PayPal unterstützen',
donate: 'Spenden', coffee: 'Spendier mir einen Kaffee', t_donate: 'clip2md unterstützen',
raw_again: 'Ohne Aufräumen', t_obs: 'In Obsidian öffnen', history: 'Verlauf', iphone: 'iPhone-Kurzbefehl',
src_article: 'Artikel extrahiert', src_cleaned: 'Seite aufgeräumt', src_reddit: 'Reddit-Thread',
s_clean: 'Aufräumen', s_cleanup: 'Webseiten aufräumen', s_cleanup_hint: 'Navigation, Cookie-Banner, Knöpfe entfernen',
s_reddit: 'Reddit-Threads aufbereiten', s_reddit_hint: 'Keine Votes, Kommentare als verschachtelte Zitate',
s_obs: 'Knopf „In Obsidian öffnen"', s_obs_hint: 'Legt die Notiz direkt im Vault an', ph_vault: 'Vault (optional)', ph_folder: 'Ordner (optional)',
s_hist: 'Letzte 10 Clips behalten', s_hist_hint: 'Nur in diesem Browser gespeichert. Standardmäßig aus.',
hist_empty: 'Noch keine Clips gespeichert.', hist_local: 'Nur in diesem Browser', hist_clear: 'Alle löschen', hist_clear_confirm: 'Gesamten Verlauf löschen?',
hist_off_confirm: 'Bisher gespeicherte Clips auch löschen?', hist_loaded: 'Clip wiederhergestellt', comments: 'Kommentare',
obs_open: 'Obsidian wird geöffnet …', obs_fail: 'Übergabe an Obsidian fehlgeschlagen — Markdown stattdessen kopieren', raw_done: 'Ohne Aufräumen eingefügt',
ios_hint: 'Tippe auf „Einfügen", um die Seite zu übernehmen',
help_html: '<p>iOS lässt Web-Apps nicht ins Teilen-Menü. Ein Kurzbefehl übernimmt das: Er kopiert die Seite und öffnet clip2md.</p><ol><li>App <b>Kurzbefehle</b> öffnen, <b>+</b> tippen, Name <b>clip2md</b>.</li><li>Auf <b>ⓘ</b> tippen, <b>Im Share-Sheet anzeigen</b> einschalten und <b>Safari-Webseiten</b>, <b>Rich Text</b>, <b>Text</b> zulassen.</li><li>Aktion <b>Details von Safari-Webseite abrufen</b> hinzufügen, auf <b>Seiteninhalt</b> stellen.</li><li>Aktion <b>In Zwischenablage kopieren</b> hinzufügen.</li><li>Aktion <b>URLs öffnen</b> mit <code>https://clip2md.heidrich-digital.de/?from=ios</code></li></ol><p>In Safari dann: Teilen → <b>clip2md</b> → <b>Einfügen</b> tippen. Beim ersten Mal fragt iOS, ob clip2md einfügen darf.</p><p>Nur markierter Text: kopieren und den Einfügen-Knopf nutzen, dafür braucht es keinen Kurzbefehl.</p>',
empty_h: 'Einfügen und los',
empty_p: 'Ein langer Reddit-Thread, ein Artikel, beliebig formatierter Text. HTML wird zu sauberem Markdown, reiner Text bleibt, wie er ist.',
paste_big: 'Aus Zwischenablage einfügen', k_paste: 'einfügen', k_save: 'speichern', k_drop: 'oder Datei hineinziehen',
@@ -487,7 +595,8 @@
var settings = {
frontMatter: LS.get('frontMatter', false), source: LS.get('source', ''), tags: LS.get('tags', ''),
stripLinks: LS.get('stripLinks', false), dropImages: LS.get('dropImages', false),
bullet: LS.get('bullet', '-'), heading: LS.get('heading', 'atx'), pattern: LS.get('pattern', 'title')
bullet: LS.get('bullet', '-'), heading: LS.get('heading', 'atx'), pattern: LS.get('pattern', 'title'),
cleanup: LS.get('cleanup', true), reddit: LS.get('reddit', true), obs: LS.get('obs', false), obsVault: LS.get('obsVault', ''), obsFolder: LS.get('obsFolder', ''), hist: LS.get('hist', false)
};
var filenameEdited = false, forceRaw = false, forceRawTimer = null, toastTimer = null, previewOn = LS.get('preview', false);
@@ -523,7 +632,7 @@
return s.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,60).replace(/-+$/g,''); }
function deriveTitle(md){ var L=md.split(/\r?\n/), i, m, s;
for(i=0;i<L.length;i++){ m=L[i].match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/); if(m&&m[1].trim()) return m[1].trim(); }
for(i=0;i<L.length;i++){ s=L[i].trim(); if(!s) continue; if(/^([-*_=#>`|]{1,}|\[.*\])$/.test(s)) continue; return s.replace(/^[#>*\-\s]+/,'').trim(); }
for(i=0;i<L.length;i++){ s=L[i].trim(); if(!s) continue; if(/^([-*_=#>`|]{1,}|\[.*\])$/.test(s)) continue; return s.replace(/^[#>*\-\s]+/,'').replace(/!?\[([^\]]*)\]\([^)]*\)/g,'$1').replace(/[*_`]/g,'').trim(); }
return ''; }
function computeFilename(md){ if (settings.pattern === 'datetime') return today()+'-'+nowTime();
var slug = slugify(deriveTitle(md)); return today()+(slug?'-'+slug:'-clip'); }
@@ -557,7 +666,7 @@
function showSource(key, ok){ srcText.textContent=t(key); srcBadge.dataset.key=key; srcBadge.classList.toggle('ok',!!ok); srcBadge.classList.add('show');
srcBadge.classList.remove('pulse'); void srcBadge.offsetWidth; srcBadge.classList.add('pulse'); }
function hideSource(){ srcBadge.classList.remove('show','ok','pulse'); srcBadge.dataset.key=''; }
function refreshAll(flash){ updateCounts(); refreshFilename(flash); updateEmpty(); schedulePreview(); }
function refreshAll(flash){ updateCounts(); refreshFilename(flash); updateEmpty(); schedulePreview(); scheduleHistory(); syncHeadButtons(); }
// ---------- Insert ----------
function insertAtCursor(str){
@@ -568,14 +677,40 @@
var s=editor.selectionStart, en=editor.selectionEnd;
editor.value=editor.value.slice(0,s)+str+editor.value.slice(en);
editor.selectionStart=editor.selectionEnd=s+str.length; }
// Clean-up (clean.js): Reddit → structured Markdown; pages → Readability or junk stripping.
function convertHtml(html){
var C=window.Clip2mdClean, r;
if(C && (settings.cleanup || settings.reddit)){
try { r=C.prepare(html, { cleanup: settings.cleanup, reddit: settings.reddit, toMd: htmlToMd, labels: { comments: t('comments') } }); } catch(e){ r=null; }
}
if(!r) return { md: htmlToMd(html), key: 'src_conv' };
var md = r.md != null ? r.md : htmlToMd(r.html);
if(settings.cleanup || r.reddit) md = C.postprocess(md, { reddit: !!r.reddit && settings.reddit });
if(r.title && md.indexOf(r.title) < 0) md = '# ' + r.title + '\n\n' + md;
else if(r.title && md.indexOf('## ' + r.title) === 0) md = md.slice(1);
var key = r.kind==='reddit' ? 'src_reddit' : r.kind==='article' ? 'src_article' : r.kind==='cleaned' ? 'src_cleaned' : 'src_conv';
return { md: md, key: key, changed: r.kind!=='plain' };
}
var lastInsert=null, rawBtn=$('rawBtn');
function ingest(html, text, raw){
var out, key, ok=false;
var out, key, ok=false, changed=false;
if(!raw && html && html.trim()){
try { out=htmlToMd(html); key='src_conv'; ok=true; if(!out && text) { out=text; key='src_raw'; ok=false; } }
try { var c=convertHtml(html); out=c.md; key=c.key; changed=!!c.changed; ok=true; if(!out && text) { out=text; key='src_raw'; ok=false; changed=false; } }
catch(err){ out=text||''; key='src_fail'; }
} else { out=text||''; key=raw?'src_raw_forced':'src_raw'; }
if(!out) return false;
insertAtCursor(out); showSource(key, ok); refreshAll(true); return true; }
if(!editor.value.trim()) historyNew();
var start=editor.selectionStart;
insertAtCursor(out); showSource(key, ok); refreshAll(true);
lastInsert = changed ? { start: start, len: out.length, html: html } : null;
rawBtn.hidden = !changed;
return true; }
rawBtn.addEventListener('click', function(){
if(!lastInsert) return;
var plain; try { plain=htmlToMd(lastInsert.html); } catch(e){ return; }
editor.focus(); editor.setSelectionRange(lastInsert.start, lastInsert.start+lastInsert.len);
insertAtCursor(plain); lastInsert=null; rawBtn.hidden=true; showSource('src_conv', true); refreshAll(false); toast(t('raw_done'));
});
editor.addEventListener('paste', function(e){
var cd=e.clipboardData||window.clipboardData; if(!cd) return;
@@ -583,7 +718,7 @@
if(!html && !text) return;
e.preventDefault(); ingest(html, text, raw);
});
editor.addEventListener('input', function(){ hideSource(); refreshAll(false); });
editor.addEventListener('input', function(){ hideSource(); rawBtn.hidden=true; lastInsert=null; refreshAll(false); });
// ---------- One-click paste (Async Clipboard API) ----------
function pasteFromClipboard(){
@@ -619,9 +754,9 @@
var r=new FileReader();
r.onerror=function(){ toast(t('readfail'), true); };
r.onload=function(){ var txt=String(r.result||''), name=f.name.toLowerCase();
if(/\.html?$/.test(name) || f.type==='text/html'){ try{ txt=htmlToMd(txt); showSource('src_conv',true); }catch(_){ showSource('src_fail',false); } }
if(/\.html?$/.test(name) || f.type==='text/html'){ try{ var cv=convertHtml(txt); txt=cv.md; showSource(cv.key,true); }catch(_){ showSource('src_fail',false); } }
else showSource('src_raw',false);
editor.value=txt;
historyNew(); editor.value=txt; rawBtn.hidden=true; lastInsert=null;
if(!filenameEdited){ var base=slugify(f.name.replace(/\.[^.]+$/,'')); if(base) filename.value=base; }
refreshAll(true); toast(t('loaded')); };
r.readAsText(f);
@@ -671,7 +806,7 @@
try{ document.execCommand('copy'); toast(t('copied')); }catch(e){ toast(t('copyfail'),true); } document.body.removeChild(ta); }
$('clear').addEventListener('click', function(){ if(editor.value && !confirm(t('clear_confirm'))) return;
editor.value=''; filenameEdited=false; hideSource(); setFnameTag(); refreshAll(false); if(!coarse) editor.focus(); });
flushHistory(); historyNew(); editor.value=''; filenameEdited=false; hideSource(); rawBtn.hidden=true; setFnameTag(); refreshAll(false); if(!coarse) editor.focus(); });
// ---------- Keyboard ----------
document.addEventListener('keydown', function(e){ var mod=e.metaKey||e.ctrlKey;
@@ -702,10 +837,13 @@
$('langBtn').addEventListener('click', function(){ setLang(lang==='de'?'en':'de'); });
// ---------- Settings ----------
function openSettings(){ settingsPop.classList.add('open'); scrim.classList.add('open'); settingsBtn.setAttribute('aria-expanded','true'); }
function closeSettings(){ settingsPop.classList.remove('open'); scrim.classList.remove('open'); settingsBtn.setAttribute('aria-expanded','false'); }
settingsBtn.addEventListener('click', function(){ settingsPop.classList.contains('open')?closeSettings():openSettings(); });
var sheets=[settingsPop, $('histPop'), $('helpPop')];
function openSheet(el){ sheets.forEach(function(x){ x.classList.toggle('open', x===el); }); scrim.classList.add('open'); settingsBtn.setAttribute('aria-expanded', el===settingsPop?'true':'false'); }
function closeSettings(){ sheets.forEach(function(x){ x.classList.remove('open'); }); scrim.classList.remove('open'); settingsBtn.setAttribute('aria-expanded','false'); }
settingsBtn.addEventListener('click', function(){ settingsPop.classList.contains('open')?closeSettings():openSheet(settingsPop); });
scrim.addEventListener('click', closeSettings);
document.querySelectorAll('[data-close]').forEach(function(b){ b.addEventListener('click', closeSettings); });
$('helpBtn').addEventListener('click', function(){ $('helpBody').innerHTML=t('help_html'); openSheet($('helpPop')); });
function bindToggle(id, key, after){ var el=$(id); el.checked=!!settings[key];
el.addEventListener('change', function(){ settings[key]=el.checked; LS.set(key,el.checked); if(after) after(); }); }
@@ -723,6 +861,91 @@
bindSelect('headingSel','heading', buildTurndown);
bindSelect('namePattern','pattern', function(){ refreshFilename(true); });
syncFmFields();
bindToggle('cleanToggle','cleanup');
bindToggle('redditToggle','reddit');
var obsFields=$('obsFields');
bindToggle('obsToggle','obs', function(){ obsFields.hidden=!settings.obs; syncHeadButtons(); });
obsFields.hidden=!settings.obs;
['obsVault','obsFolder'].forEach(function(k){ var el=$(k); el.value=settings[k]; el.addEventListener('input', function(){ settings[k]=el.value.trim(); LS.set(k, settings[k]); }); });
bindToggle('histToggle','hist', function(){
if(settings.hist){ historyNew(); scheduleHistory(); }
else if(confirm(t('hist_off_confirm'))) H.clear();
syncHeadButtons(); });
// ---------- Head buttons ----------
function syncHeadButtons(){
var has=editor.value.trim().length>0;
$('obsBtn').hidden = !(settings.obs && has);
$('histBtn').hidden = !settings.hist;
}
// ---------- Obsidian ----------
$('obsBtn').addEventListener('click', function(){
if(!editor.value.trim()){ toast(t('nocopy'), true); return; }
var content=fullContent();
var name=(filename.value.trim()||computeFilename(editor.value)).replace(/[\/\\:*?"<>|#^\[\]]+/g,'-').replace(/\.md$/i,'');
var folder=(settings.obsFolder||'').replace(/^\/+|\/+$/g,'');
var base='obsidian://new?'+(settings.obsVault?'vault='+encodeURIComponent(settings.obsVault)+'&':'')+(folder?'file='+encodeURIComponent(folder+'/'+name):'name='+encodeURIComponent(name));
function go(u){ toast(t('obs_open')); window.location.href=u; }
// Long threads do not fit into a URL: hand the text over via the clipboard instead.
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(content).then(function(){ go(base+'&clipboard=true'); }, function(){
if(content.length<6000) go(base+'&content='+encodeURIComponent(content)); else toast(t('obs_fail'), true, 4000); });
} else if(content.length<6000) go(base+'&content='+encodeURIComponent(content));
else toast(t('obs_fail'), true, 4000);
});
// ---------- History (IndexedDB, opt-in) ----------
var H=(function(){
var dbp=null, MAX=10;
function db(){ if(dbp) return dbp; dbp=new Promise(function(res, rej){ try { var r=indexedDB.open('clip2md',1);
r.onupgradeneeded=function(){ r.result.createObjectStore('clips',{keyPath:'id'}); };
r.onsuccess=function(){ res(r.result); }; r.onerror=function(){ rej(r.error); }; } catch(e){ rej(e); } }); return dbp; }
function tx(mode, fn){ return db().then(function(d){ return new Promise(function(res, rej){ var x=d.transaction('clips',mode), st=x.objectStore('clips'), out=fn(st);
x.oncomplete=function(){ res(out && out.result!==undefined ? out.result : out); }; x.onerror=function(){ rej(x.error); }; }); }); }
return {
put: function(item){ return tx('readwrite', function(st){ st.put(item); }).then(function(){ return H.list(); }).then(function(all){
if(all.length>MAX) return tx('readwrite', function(st){ all.slice(MAX).forEach(function(i){ st.delete(i.id); }); }); }); },
list: function(){ return tx('readonly', function(st){ return st.getAll(); }).then(function(a){ return (a||[]).sort(function(x,y){ return y.ts-x.ts; }); }); },
del: function(id){ return tx('readwrite', function(st){ st.delete(id); }); },
clear: function(){ return tx('readwrite', function(st){ st.clear(); }); }
};
})();
var histId=null, histTimer=null;
function historyNew(){ histId=null; }
function flushHistory(){ clearTimeout(histTimer); saveHistory(); }
function scheduleHistory(){ if(!settings.hist) return; clearTimeout(histTimer); histTimer=setTimeout(saveHistory, 1200); }
function saveHistory(){
if(!settings.hist || !window.indexedDB) return; var md=editor.value; if(!md.trim()) return;
if(!histId) histId=Date.now();
H.put({ id: histId, ts: Date.now(), title: deriveTitle(md).slice(0,120) || filename.value, name: filename.value, md: md }).catch(function(){});
}
function renderHistory(){
var ul=$('histList'); ul.innerHTML='';
H.list().then(function(items){
$('histEmpty').hidden = items.length>0; $('histClear').hidden = !items.length;
items.forEach(function(it){
var li=document.createElement('li'), b=document.createElement('button'), d=document.createElement('button');
b.className='load'; b.type='button';
var ti=document.createElement('b'); ti.textContent=it.title||it.name||'—';
var meta=document.createElement('span'); meta.textContent=new Date(it.ts).toLocaleString(lang==='de'?'de-DE':'en-GB',{dateStyle:'short',timeStyle:'short'})+' · '+countWords(it.md)+' '+t('words');
b.appendChild(ti); b.appendChild(meta);
b.addEventListener('click', function(){
flushHistory();
editor.value=it.md; histId=it.id; filenameEdited=!!it.name; if(it.name) filename.value=it.name; setFnameTag();
hideSource(); rawBtn.hidden=true; refreshAll(false); closeSettings(); toast(t('hist_loaded')); });
d.className='del'; d.type='button'; d.setAttribute('aria-label','Delete'); d.textContent='✕';
d.addEventListener('click', function(){ H.del(it.id).then(renderHistory); if(histId===it.id) histId=null; });
li.appendChild(b); li.appendChild(d); ul.appendChild(li);
});
}).catch(function(){ $('histEmpty').hidden=false; });
}
$('histBtn').addEventListener('click', function(){ flushHistory(); setTimeout(renderHistory, 50); openSheet($('histPop')); });
$('histClear').addEventListener('click', function(){ if(confirm(t('hist_clear_confirm'))) H.clear().then(renderHistory); });
// ---------- Donate (only shown once a working link is configured) ----------
var DONATE_URL='';
if(DONATE_URL){ document.querySelectorAll('[data-donate]').forEach(function(a){ a.href=DONATE_URL; a.target='_blank'; a.rel='noopener nofollow'; a.hidden=false; }); }
// ---------- Init ----------
setTheme(document.documentElement.getAttribute('data-theme')==='dark'?'dark':'light', false);
@@ -735,11 +958,13 @@
(function(){
var q; try { q=new URLSearchParams(location.search); } catch(e){ return; }
var title=(q.get('title')||'').trim(), text=(q.get('text')||'').trim(), url=(q.get('url')||'').trim();
if(q.get('from')==='ios'){ try { history.replaceState(null, '', location.pathname); } catch(e){}
setTimeout(function(){ toast(t('ios_hint'), false, 4500); var pb=$('pasteBtn'); pb.classList.add('nudge'); $('pasteBig').classList.add('nudge'); }, 300); return; }
if(!title && !text && !url) return;
if(!url){ var m=text.match(/https?:\/\/\S+$/); if(m){ url=m[0]; text=text.slice(0,m.index).trim(); } }
var md=(title && text.indexOf(title)!==0 ? '# '+title+'\n\n' : '')+text+(url && text.indexOf(url)<0 ? (text?'\n\n':'')+'<'+url+'>' : '');
if(url){ settings.source=url; $('fmSource').value=url; }
editor.value=md; showSource('src_raw', false); refreshAll(true);
historyNew(); editor.value=md; showSource('src_raw', false); refreshAll(true);
try { history.replaceState(null, '', location.pathname); } catch(e){}
})();
if(!coarse) editor.focus();
+3 -1
View File
@@ -2,7 +2,7 @@
Pages (navigations, .html): network first, cache only as offline fallback —
so new or changed pages are never frozen for existing users.
Static assets (vendor, fonts, icons): cache first. Bump CACHE on asset changes. */
var CACHE = 'clip2md-v5';
var CACHE = 'clip2md-v6';
var ASSETS = [
'/',
'/index.html',
@@ -17,6 +17,8 @@ var ASSETS = [
'/vendor/turndown-plugin-gfm.js',
'/vendor/marked.umd.js',
'/vendor/purify.min.js',
'/vendor/Readability.js',
'/clean.js',
'/vendor/fonts/space-grotesk-latin-400-normal.woff2',
'/vendor/fonts/space-grotesk-latin-500-normal.woff2',
'/vendor/fonts/space-grotesk-latin-700-normal.woff2',
+13
View File
@@ -0,0 +1,13 @@
Copyright (c) 2010 Arc90 Inc
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+2786
View File
File diff suppressed because it is too large. Load diff