378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
/**
|
|
* 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);
|