/** * shaarit-rules.js * * Règles métier partagées avec l'application Android ShaarIt. * Source de vérité unique pour : * - la détection des notes / todos / épinglés ; * - les tags techniques cachés par défaut ; * - la détection de type de contenu (video, podcast, radio, music, article, * news, social, repository/dev, shopping, image, pdf, document) ; * - la génération d'URLs internes (notes / todos). * * Non destructif : toutes les conventions existantes du thème web continuent * d'être reconnues en lecture. Les règles Android viennent s'y ajouter. * * Exposé sur `window.ShaarItRules`. */ (function (global) { "use strict"; // --------------------------------------------------------------------------- // Tags système (cachés par défaut côté UI) // --------------------------------------------------------------------------- var PRESET_SYSTEM_TAGS = [ { name: "note", desc: "Notes - Identifiant interne", hidden: true }, { name: "shaarli-note", desc: "Notes - Alias legacy", hidden: true }, { name: "todo", desc: "Tâches - Identifiant interne", hidden: true }, { name: "shaarli-todo", desc: "Tâches - Alias legacy", hidden: true }, { name: "shaarli-pin", desc: "Épinglé - Favoris en haut", hidden: true }, { name: "note-color-*", desc: "Couleurs des notes (wildcard)", hidden: true }, { name: "notebg-*", desc: "Fonds des notes (wildcard)", hidden: true }, { name: "notefilter-*", desc: "Filtres des notes (wildcard)", hidden: true }, { name: "font-*", desc: "Couleur de police (wildcard)", hidden: true }, { name: "readitlater", desc: "À lire plus tard", hidden: true }, { name: "brain-dump", desc: "Capture rapide d'idées", hidden: true }, { name: "shaarli-archive", desc: "Archivé", hidden: false }, { name: "shaarli-archiver",desc: "Archivé - Alias legacy", hidden: false } ]; // --------------------------------------------------------------------------- // Détection d'entités (note / todo / pin) - compatibilité Android + legacy // --------------------------------------------------------------------------- function toLower(x) { return String(x || "").toLowerCase(); } function asTagArray(tags) { if (Array.isArray(tags)) return tags.map(toLower); if (typeof tags === "string") { return tags.split(/[\s,|]+/).map(toLower).filter(Boolean); } return []; } /** * Détection d'une note. * Compatible avec les règles Android : * - URL `note://` (mais pas `note://todo-`) * - URL `http://shaare` / `/shaare` * - URL `https://shaarit.app/note/...` * - Tag `note`, `#note`, `shaarli-note` */ function isNote(link) { if (!link) return false; var url = String(link.url || "").trim(); var tags = asTagArray(link.tags); if (url) { var u = url.toLowerCase(); if (u.indexOf("note://") === 0 && u.indexOf("note://todo-") !== 0) return true; if (u.indexOf("http://shaare") === 0) return true; if (u.indexOf("/shaare") === 0) return true; if (u.indexOf("https://shaarit.app/note/") === 0) return true; if (u.indexOf("http://shaarit.app/note/") === 0) return true; } for (var i = 0; i < tags.length; i++) { if (tags[i] === "note" || tags[i] === "#note" || tags[i] === "shaarli-note") return true; } return false; } /** * Détection d'une tâche. * Compatible avec les règles Android : * - URL `note://todo-...` * - URL `https://shaarit.app/todo/...` * - URL `http://shaarli-todo` (legacy web) * - Tag `todo`, `#todo`, `shaarli-todo` */ function isTodo(link) { if (!link) return false; var url = String(link.url || "").trim(); var tags = asTagArray(link.tags); if (url) { var u = url.toLowerCase(); if (u.indexOf("note://todo-") === 0) return true; if (u.indexOf("https://shaarit.app/todo/") === 0) return true; if (u.indexOf("http://shaarit.app/todo/") === 0) return true; if (u.indexOf("http://shaarli-todo") === 0) return true; if (u.indexOf("https://shaarli-todo") === 0) return true; } for (var i = 0; i < tags.length; i++) { if (tags[i] === "todo" || tags[i] === "#todo" || tags[i] === "shaarli-todo") return true; } return false; } /** Détection épinglé via tag `shaarli-pin`. */ function isPinned(link) { if (!link) return false; var tags = asTagArray(link.tags); return tags.indexOf("shaarli-pin") !== -1; } /** Détection archive (tag `shaarli-archive` ou legacy `shaarli-archiver`). */ function isArchived(link) { if (!link) return false; var tags = asTagArray(link.tags); return tags.indexOf("shaarli-archive") !== -1 || tags.indexOf("shaarli-archiver") !== -1; } // --------------------------------------------------------------------------- // Génération d'URLs internes (style Android) // --------------------------------------------------------------------------- function randomId() { // Identifiant court type base36, 12 caractères. if (global.crypto && global.crypto.getRandomValues) { var arr = new Uint8Array(9); global.crypto.getRandomValues(arr); var out = ""; for (var i = 0; i < arr.length; i++) { out += ("0" + arr[i].toString(36)).slice(-2); } return out.substring(0, 12); } return (Date.now().toString(36) + Math.random().toString(36).slice(2, 10)).substring(0, 12); } function generateNoteUrl() { return "https://shaarit.app/note/" + randomId(); } function generateTodoUrl() { return "https://shaarit.app/todo/" + randomId(); } // --------------------------------------------------------------------------- // Détection de type de contenu (ContentType, règles Android) // --------------------------------------------------------------------------- var CONTENT_TYPES = { UNKNOWN: "unknown", ARTICLE: "article", VIDEO: "video", PODCAST: "podcast", IMAGE: "image", PDF: "pdf", REPOSITORY: "repository", DOCUMENT: "document", SOCIAL: "social", SHOPPING: "shopping", NEWSLETTER: "newsletter", MUSIC: "music", RADIO: "radio", NEWS: "news" }; // Tags auto-ajoutés par type de contenu (conformes règles Android UI). var CONTENT_TYPE_TAGS = { video: ["video"], podcast: ["podcast"], radio: ["radio"], music: ["music"], article: ["article"], news: ["news"], social: ["social"], repository: ["repository", "dev"], shopping: ["shopping"], newsletter: ["newsletter"], image: ["image"], pdf: ["pdf"], document: [], unknown: [] }; function parseHost(url) { try { var u = new URL(url); return (u.hostname || "").toLowerCase(); } catch (e) { // Fallback très simple si URL invalide var m = String(url || "").match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i); return m ? m[1].toLowerCase() : ""; } } function hostContainsAny(host, list) { for (var i = 0; i < list.length; i++) { if (host.indexOf(list[i]) !== -1) return true; } return false; } // --- Audio : RADIO > PODCAST > MUSIC --- var RADIO_HOSTS = [ "playerservices.streamtheworld.com", "icecast", "shoutcast", "fluxradios.com", "tunein.com", "radio.garden", "mytuner-radio.com", "iheart.com", "onlineradiobox.com", "radio.net" ]; function isRadio(url, host) { if (/\.(m3u|m3u8|pls)(\?|$)/i.test(url)) return true; if (hostContainsAny(host, RADIO_HOSTS)) return true; if (host.indexOf("stream.") === 0 || host.indexOf("live.") === 0) return true; if (host.indexOf("ici.radio-canada.ca") !== -1 || host.indexOf("radio-canada.ca") !== -1) { if (/\/balados(\/|$)/i.test(url)) return false; if (/\/(direct|premiere|audio-fil)(\/|$)/i.test(url)) return true; } return false; } var PODCAST_HOSTS = [ "podcasts.apple.com", "overcast.fm", "pocketcasts.com", "castbox.fm", "stitcher.com", "acast.com", "anchor.fm", "libsyn.com", "simplecast.com", "buzzsprout.com" ]; function isPodcast(url, host) { if (hostContainsAny(host, PODCAST_HOSTS)) return true; if (/open\.spotify\.com\/(show|episode)\//i.test(url)) return true; if (/\/balados(\/|$)|\/ohdio\/balados(\/|$)/i.test(url)) return true; if (/\.(xml|rss)(\?|$)/i.test(url)) return true; return false; } var MUSIC_HOSTS = [ "music.apple.com", "deezer.com", "tidal.com", "music.youtube.com", "bandcamp.com", "soundcloud.com", "mixcloud.com", "beatport.com" ]; function isMusic(url, host) { if (hostContainsAny(host, MUSIC_HOSTS)) return true; if (/open\.spotify\.com\/(track|album|artist|playlist)\//i.test(url)) return true; return false; } // --- Catégories web --- var VIDEO_HOSTS = ["youtube.com", "youtu.be", "vimeo.com", "dailymotion.com", "twitch.tv", "netflix.com"]; function isVideo(host) { return hostContainsAny(host, VIDEO_HOSTS); } var SOCIAL_HOSTS = [ "facebook.com", "instagram.com", "tiktok.com", "twitter.com", "x.com", "linkedin.com", "reddit.com", "snapchat.com", "pinterest.com", "mastodon" ]; function isSocial(host) { return hostContainsAny(host, SOCIAL_HOSTS); } var REPO_HOSTS = ["github.com", "gitlab.com", "bitbucket.org", "stackoverflow.com"]; function isRepository(host) { return hostContainsAny(host, REPO_HOSTS); } var SHOPPING_HOSTS = ["amazon", "ebay", "etsy.com", "aliexpress.com", "shopify"]; function isShopping(host) { return hostContainsAny(host, SHOPPING_HOSTS); } var DOCUMENT_HOSTS = [ "docs.google.com", "drive.google.com", "notion.so", "trello.com", "jira", "confluence" ]; function isDocument(host) { return hostContainsAny(host, DOCUMENT_HOSTS); } var NEWS_HOSTS = [ "news", "nytimes", "lemonde", "bbc", "cnn", "reuters", "theguardian", "lefigaro" ]; function isNews(host) { return hostContainsAny(host, NEWS_HOSTS); } var NEWSLETTER_HOSTS = ["substack", "revue", "mailchimp"]; function isNewsletter(host) { return hostContainsAny(host, NEWSLETTER_HOSTS); } var IMAGE_HOSTS = ["imgur.com", "flickr.com"]; function isImageUrl(url, host) { if (/\.(jpe?g|png|gif|webp|bmp|svg)(\?|$)/i.test(url)) return true; return hostContainsAny(host, IMAGE_HOSTS); } function isPdfUrl(url) { return /\.pdf(\?|$)/i.test(url); } /** * Détecte le type de contenu d'une URL (règles Android). * @returns {{type: string, tags: string[]}} */ function detectContentType(url) { var raw = String(url || "").trim(); if (!raw) return { type: CONTENT_TYPES.UNKNOWN, tags: [] }; // URLs internes : pas de détection var low = raw.toLowerCase(); if (low.indexOf("note://") === 0 || low.indexOf("https://shaarit.app/note/") === 0 || low.indexOf("https://shaarit.app/todo/") === 0 || low.indexOf("http://shaare") === 0 || low.indexOf("/shaare") === 0 || low.indexOf("http://shaarli-todo") === 0) { return { type: CONTENT_TYPES.UNKNOWN, tags: [] }; } var host = parseHost(raw); // 1. Fichiers (priorité haute sur l'extension) if (isPdfUrl(raw)) return { type: CONTENT_TYPES.PDF, tags: CONTENT_TYPE_TAGS.pdf }; if (isImageUrl(raw, host))return { type: CONTENT_TYPES.IMAGE, tags: CONTENT_TYPE_TAGS.image }; // 2. Audio : RADIO > PODCAST > MUSIC if (isRadio(raw, host)) return { type: CONTENT_TYPES.RADIO, tags: CONTENT_TYPE_TAGS.radio }; if (isPodcast(raw, host)) return { type: CONTENT_TYPES.PODCAST, tags: CONTENT_TYPE_TAGS.podcast }; if (isMusic(raw, host)) return { type: CONTENT_TYPES.MUSIC, tags: CONTENT_TYPE_TAGS.music }; // 3. Vidéo if (isVideo(host)) return { type: CONTENT_TYPES.VIDEO, tags: CONTENT_TYPE_TAGS.video }; // 4. Plateformes spécifiques if (isRepository(host)) return { type: CONTENT_TYPES.REPOSITORY, tags: CONTENT_TYPE_TAGS.repository }; if (isDocument(host)) return { type: CONTENT_TYPES.DOCUMENT, tags: CONTENT_TYPE_TAGS.document }; if (isSocial(host)) return { type: CONTENT_TYPES.SOCIAL, tags: CONTENT_TYPE_TAGS.social }; if (isShopping(host)) return { type: CONTENT_TYPES.SHOPPING, tags: CONTENT_TYPE_TAGS.shopping }; if (isNewsletter(host)) return { type: CONTENT_TYPES.NEWSLETTER, tags: CONTENT_TYPE_TAGS.newsletter }; if (isNews(host)) return { type: CONTENT_TYPES.NEWS, tags: CONTENT_TYPE_TAGS.news }; return { type: CONTENT_TYPES.UNKNOWN, tags: [] }; } /** * Fusion d'un tableau de tags existants avec les tags auto-détectés. * Conserve l'ordre existant, ajoute uniquement les tags manquants. */ function mergeAutoTags(existingTags, autoTags) { var list = Array.isArray(existingTags) ? existingTags.slice() : []; var lower = list.map(toLower); (autoTags || []).forEach(function (t) { var clean = String(t || "").trim(); if (!clean) return; if (lower.indexOf(clean.toLowerCase()) === -1) { list.push(clean); lower.push(clean.toLowerCase()); } }); return list; } // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- var api = { PRESET_SYSTEM_TAGS: PRESET_SYSTEM_TAGS, CONTENT_TYPES: CONTENT_TYPES, CONTENT_TYPE_TAGS: CONTENT_TYPE_TAGS, isNote: isNote, isTodo: isTodo, isPinned: isPinned, isArchived: isArchived, generateNoteUrl: generateNoteUrl, generateTodoUrl: generateTodoUrl, detectContentType: detectContentType, mergeAutoTags: mergeAutoTags }; global.ShaarItRules = api; })(typeof window !== "undefined" ? window : this);