From f88ae5825376720a543917f0229d1f346c898269 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:48:03 +0100 Subject: [PATCH 01/30] First import --- snippet/HookableValue.js | 74 +++++++++ snippet/addStyle.js | 29 ++++ snippet/bindOnChange.js | 21 +++ snippet/bindOnClick.js | 30 ++++ snippet/downloadText.js | 14 ++ snippet/getElements.js | 9 ++ snippet/getSubElements.js | 8 + snippet/registerDomNodeMutated.js | 24 +++ snippet/registerDomNodeMutatedUnique.js | 26 ++++ src/common.props.json | 9 ++ src/la-cale/la-cale-bot.user.js | 192 ++++++++++++++++++++++++ src/website.css | 1 + 12 files changed, 437 insertions(+) create mode 100644 snippet/HookableValue.js create mode 100644 snippet/addStyle.js create mode 100644 snippet/bindOnChange.js create mode 100644 snippet/bindOnClick.js create mode 100644 snippet/downloadText.js create mode 100644 snippet/getElements.js create mode 100644 snippet/getSubElements.js create mode 100644 snippet/registerDomNodeMutated.js create mode 100644 snippet/registerDomNodeMutatedUnique.js create mode 100644 src/common.props.json create mode 100644 src/la-cale/la-cale-bot.user.js create mode 100644 src/website.css diff --git a/snippet/HookableValue.js b/snippet/HookableValue.js new file mode 100644 index 0000000..9ec5788 --- /dev/null +++ b/snippet/HookableValue.js @@ -0,0 +1,74 @@ +/** + * A class representing a value that can have hooks on change + * @template T The type of the value + */ +class HookableValue { + /** + * Constructor + * @param {string} name The name of the hook + * @param {T|null} defaultValue The default value + */ + constructor(name, defaultValue = null) { + this._name = name; + this._value = defaultValue; + this.callbacks = []; + } + + /** + * Sets the value and calls the hooks if the value changed + * + * @param {T} newValue The new value + * @returns {void} + */ + async setValue(newValue) { + const oldValue = this.value; + if (oldValue !== newValue) { + this._value = newValue; + for (const callback of this.callbacks) { + await callback(newValue, oldValue); + } + } + } + + /** + * Gets the value + * + * @returns {T} The current value + */ + getValue() { + return this._value; + } + + /** + * Register a callback to be called when the value changes + * @param {(newValue:T, oldValue:T)=>Promise} callback The callback (that may be async) + * @returns {()=>void} The unregister function + */ + register(callback) { + this.callbacks.push(callback); + return () => { + this.callbacks = this.callbacks.filter(cb => cb !== callback); + } + } + + /** + * Clears all registered callbacks + * @returns {void} + */ + clearCallbacks() { + this.callbacks = []; + } + + get value() { + return this.getValue(); + } + + set value(newValue) { + this.setValue(newValue); + } + + get name() { + return this._name; + } +} +/** @typedef {HookableValue} HookableValue */ \ No newline at end of file diff --git a/snippet/addStyle.js b/snippet/addStyle.js new file mode 100644 index 0000000..0bade03 --- /dev/null +++ b/snippet/addStyle.js @@ -0,0 +1,29 @@ +/** + * Add a new css string to the page + * + * @param {string} styleText The CSS string to pass + * @returns {void} + */ +const addStyle = (() => { + let styleElement = null; + let styleContent = null; + + /** + * Add a new css string to the page + * + * @param {string} styleText The CSS string to pass + * @returns {void} + */ + return (styleText) => { + if (styleElement === null) { + styleElement = document.createElement('style'); + styleContent = ""; + document.head.appendChild(styleElement); + } else { + styleContent += "\n"; + } + + styleContent += styleText; + styleElement.textContent = styleContent; + }; +})(); diff --git a/snippet/bindOnChange.js b/snippet/bindOnChange.js new file mode 100644 index 0000000..dad061a --- /dev/null +++ b/snippet/bindOnChange.js @@ -0,0 +1,21 @@ +/** + * Bind an onChange handler an element. Returns uninstall handler + * + * @param {HTMLElement} element The element to bind the handler + * @param {()=>boolean|undefined} callback The onChange handler + * @returns {()=>{}} + */ +const bindOnChange = (element, callback) => { + const onChange = (e) => { + const result = callback() + if (result !== false) { + e.preventDefault() + e.stopImmediatePropagation() + } + } + element.addEventListener('change', onChange, true); + + return () => { + element.removeEventListener('change', onChange, true); + } +} diff --git a/snippet/bindOnClick.js b/snippet/bindOnClick.js new file mode 100644 index 0000000..669fc7e --- /dev/null +++ b/snippet/bindOnClick.js @@ -0,0 +1,30 @@ +/** + * Bind an onClick handler an element. Returns uninstall handler + * + * @param {HtmlElement} element The element to bind the handler + * @param {()=>boolean|undefined} callback The onClick handler + * @param {()=>boolean|undefined} callbackCtrl The onClick handler for ctrl+click + * @returns {()=>{}} + */ +const bindOnClick = (element, callback, callbackCtrl) => { + const onClick = (e) => { + let callbackToExecute = null; + if (e.ctrlKey && callbackCtrl) { + callbackToExecute = callbackCtrl; + } else { + callbackToExecute = callback; + } + if (callbackToExecute) { + const result = callbackToExecute() + if (result !== false) { + e.preventDefault() + e.stopImmediatePropagation() + } + } + } + element.addEventListener('click', onClick, true); + + return () => { + element.removeEventListener('click', onClick, true); + } +} diff --git a/snippet/downloadText.js b/snippet/downloadText.js new file mode 100644 index 0000000..c855cd2 --- /dev/null +++ b/snippet/downloadText.js @@ -0,0 +1,14 @@ +/** + * Download a text file with the given filename and content + * + * @param {string} fileName The text filename to download + * @param {string} content The text content as a string + * @param {Object} options The download options + * @param {string} option.mimeType The mime type of the text content + */ +const downloadText = async (fileName, content, options = {}) => { + const link = document.createElement('a'); + link.href = URL.createObjectURL(new Blob([content], { type: options.mimeType || 'text/plain' })); + link.download = fileName; + link.click(); +} diff --git a/snippet/getElements.js b/snippet/getElements.js new file mode 100644 index 0000000..0d9606c --- /dev/null +++ b/snippet/getElements.js @@ -0,0 +1,9 @@ +// @import{getSubElements} + +/** + * Request some elements from the current document + * + * @param {string} query The query + * @returns {[HtmlElement]} + */ +const getElements = (query) => getSubElements(document, query) diff --git a/snippet/getSubElements.js b/snippet/getSubElements.js new file mode 100644 index 0000000..9130a31 --- /dev/null +++ b/snippet/getSubElements.js @@ -0,0 +1,8 @@ +/** + * Request some sub elements from an element + * + * @param {HTMLElement} element The element to query + * @param {string} query The query + * @returns {[HTMLElement]} + */ +const getSubElements = (element, query) => [...element.querySelectorAll(query)] diff --git a/snippet/registerDomNodeMutated.js b/snippet/registerDomNodeMutated.js new file mode 100644 index 0000000..b7110fe --- /dev/null +++ b/snippet/registerDomNodeMutated.js @@ -0,0 +1,24 @@ +/** + * Call the callback when the document change + * Handle the fact that the callback can't be called while aleady being called (no stackoverflow). + * Use the register pattern thus return the unregister function as a result + * @param {()=>()} callback + * @return {()=>{}} The unregister function + */ +const registerDomNodeMutated = (callback) => { + let callbackInProgress = false + + const action = () => { + if (!callbackInProgress) { + callbackInProgress = true + callback() + callbackInProgress = false + } + } + + const mutationObserver = new MutationObserver((mutationsList, observer) => { action() }); + action() + mutationObserver.observe(document.documentElement, { childList: true, subtree: true }); + + return () => mutationObserver.disconnect() +} diff --git a/snippet/registerDomNodeMutatedUnique.js b/snippet/registerDomNodeMutatedUnique.js new file mode 100644 index 0000000..3b6fd88 --- /dev/null +++ b/snippet/registerDomNodeMutatedUnique.js @@ -0,0 +1,26 @@ +// @import{registerDomNodeMutated} +/** + * Call the callback once per element provided by the elementProvider when the document change + * Handle the fact that the callback can't be called while aleady being called (no stackoverflow). + * Use the register pattern thus return the unregister function as a result + * + * Ensure that when an element matching the query elementProvider, the callback is called with the element + * exactly once for each element + * @param {()=>[HTMLElement]} elementProvider + * @param {(element: HTMLElement)=>{}} callback + */ +const registerDomNodeMutatedUnique = (elementProvider, callback) => { + const domNodesHandled = new Set() + + return registerDomNodeMutated(() => { + for (let element of elementProvider()) { + if (!domNodesHandled.has(element)) { + domNodesHandled.add(element) + const result = callback(element) + if (result === false) { + domNodesHandled.delete(element) + } + } + } + }) +} diff --git a/src/common.props.json b/src/common.props.json new file mode 100644 index 0000000..a1abf23 --- /dev/null +++ b/src/common.props.json @@ -0,0 +1,9 @@ +{ + "namespace": "https://github.com/kabertools/userscripts/", + "author": "kaberly", + "homepage": "https://github.com/kabertools/userscripts/", + "supportURL": "https://github.com/kabertools/userscripts/", + "grant": [ + "none" + ] +} diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js new file mode 100644 index 0000000..54f96ee --- /dev/null +++ b/src/la-cale/la-cale-bot.user.js @@ -0,0 +1,192 @@ +// ==UserScript== +// @match https://la-cale.space/taverne +// ==/UserScript== + +// @import{getElements} +// @import{getSubElements} +// @import{registerDomNodeMutated} +// @import{registerDomNodeMutatedUnique} +// @import{downloadText} +// @import{HookableValue} + +class LaCabot { + constructor() { + this._name = 'LaCabot' + this._version = '0.0.1' + this._onMessageCallbacks = [] + this._onInstalledCallbacks = [] + this._titleZone = null + this._contentZone = null + this._writeZone = null + } + + install() { + registerDomNodeMutatedUnique(() => getElements('main>div>div'), (element) => { + console.log({ element, ecl: element?.children?.length }) + if (element && element.children.length === 3) { + console.log({ c: element.children }) + const [titleZone, contentZone, writeZone] = element.children + if (!titleZone || !contentZone || !writeZone) { + return false + } + console.log({ titleZone, contentZone, writeZone }) + this._titleZone = titleZone + this._contentZone = contentZone + this._writeZone = writeZone + registerDomNodeMutatedUnique(() => getSubElements(this._contentZone, 'div.h-full.overflow-y-auto>div'), (line) => { + // console.log({line}) + if (line.children.length == 3) { + const props = {} + if (line.children && line.children.length >= 3 && line.children[1] && line.children[1].children.length >= 2) { + const userElements = line.children[1].children[0].children + const messageElement = line.children[1].children[1] + if (userElements.length >= 3) { + props.user = userElements[0].textContent + const fullGradeText = userElements[1].textContent + const fullGradeSep = fullGradeText.indexOf(' ') + props.grade = fullGradeText.slice(fullGradeSep + 1) + props.iconGrade = fullGradeText.slice(0, fullGradeSep) + props.serverDate = userElements[2].textContent + props.clientDate = new Date().toISOString() + } + if (messageElement) { + props.message = messageElement.textContent + props.messageElement = messageElement + } + } + this._onMessage(props) + } + + return true + }) + this._onInstalled(); + return true + } + return false + }) + } + + registerOnMessage(callback) { + this._onMessageCallbacks.push(callback) + return () => { + this._onMessageCallbacks.splice(this._onMessageCallbacks.indexOf(callback), 1) + } + } + + registerOnInstalled(callback) { + this._onInstalledCallbacks.push(callback) + return () => { + this._onInstalledCallbacks.splice(this._onInstalledCallbacks.indexOf(callback), 1) + } + } + + _onMessage(props) { + // console.log({ ...props }) + for (const callback of this._onMessageCallbacks) { + callback(props) + } + } + + _onInstalled() { + for (const callback of this._onInstalledCallbacks) { + callback() + } + } + + addButton(icon, onClick) { + const button = document.createElement('button') + button.textContent = icon; + [ + "items-center", + "justify-center", + "gap-2", + "whitespace-nowrap", + "rounded-md", + "text-sm", + "font-medium", + "transition-colors", + "focus-visible:outline-none", + "focus-visible:ring-1", + "focus-visible:ring-ring", + "disabled:pointer-events-none", + "disabled:opacity-50", + "[&_svg]:pointer-events-none", + "[&_svg]:size-4", + "[&_svg]:shrink-0", + "hover:bg-accent", + "h-9", + "w-9", + "hidden", + "sm:flex", + "text-text-primary-medium", + "hover:text-brand-primary", + "cursor-pointer", + "relative" + ].forEach(c => button.classList.add(c)) + const titleBar = this._titleZone.children[0] + titleBar.insertBefore(button, titleBar.children[1]) + button.addEventListener('click', onClick) + return button; + } +} + + +const addLogManagement = (laCabot) => { + let logs = { + idRange: null, + day: null, + messages: [], + isEmpty: new HookableValue('isEmpty', true), + } + window.logs = logs + + const emptyLogs = () => { + if (logs.messages.length > 0) { + console.log('Log for idRange', logs.idRange, '\n', logs.messages.join('\n')) + downloadText(`la-cale-log-${logs.day}--${logs.idRange.replaceAll(':', '-')}x.txt`, logs.messages.join('\n')) + } + logs.messages = [] + logs.isEmpty.setValue(true) + } + logs.emptyLogs = emptyLogs + + laCabot.registerOnMessage((props) => { + const idRange = props.serverDate.slice(0, 4) + if (logs.idRange !== idRange) { + emptyLogs() + logs.day = props.clientDate.slice(0, 10) + logs.idRange = idRange + } + logs.messages.push(`[${props.serverDate}] ${props.iconGrade}${props.user}: ${props.message}`) + logs.isEmpty.setValue(false) + }) + + laCabot.registerOnInstalled(() => { + const buttonClean = laCabot.addButton('🧹', () => { + emptyLogs(); + }); + + window.buttonClean = buttonClean; + + logs.isEmpty.register((newValue, oldValue) => { + buttonClean.disabled = newValue; + }); + }); +} + +const main = async () => { + const laCabot = new LaCabot() + laCabot.install() + laCabot.registerOnMessage((props) => { + console.log('New message received:', props) + }) + window.laCabot = laCabot + + laCabot.registerOnInstalled(() => { + console.log('LaCabot installed') + }); + + addLogManagement(laCabot) +} + +main() \ No newline at end of file diff --git a/src/website.css b/src/website.css new file mode 100644 index 0000000..58b7b7d --- /dev/null +++ b/src/website.css @@ -0,0 +1 @@ +/* Put here the custom css content you want for your generated user scripts/style website */ From e972c584830ecfc9d22ba2e5a339cd4ee6069263 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:34:52 +0100 Subject: [PATCH 02/30] Add various usefull snippet of code for userscripts --- snippet/DescrGen.js | 30 ++++++++++++++ snippet/capitalize.js | 7 ++++ snippet/capitalize.test.js | 12 ++++++ snippet/copyTextToClipboard.js | 14 +++++++ snippet/getMiBSize.js | 9 ++++ snippet/getMiBSize.test.js | 14 +++++++ snippet/getSlug.js | 20 +++++++++ snippet/getSlug.test.js | 36 ++++++++++++++++ snippet/getSsmHookableValue.js | 15 +++++++ snippet/getSsmHookableValue.test.js | 48 ++++++++++++++++++++++ snippet/getSsmHookableValueMonkeyGetSet.js | 18 ++++++++ snippet/getSsmLocalState.js | 12 ++++++ snippet/getSsmState.js | 8 ++++ snippet/getSsmValue.js | 14 +++++++ snippet/monkeyGetSetValue.js | 17 ++++++++ snippet/monkeySetValue.js | 9 ++++ snippet/setSsmValue.js | 12 ++++++ 17 files changed, 295 insertions(+) create mode 100644 snippet/DescrGen.js create mode 100644 snippet/capitalize.js create mode 100644 snippet/capitalize.test.js create mode 100644 snippet/copyTextToClipboard.js create mode 100644 snippet/getMiBSize.js create mode 100644 snippet/getMiBSize.test.js create mode 100644 snippet/getSlug.js create mode 100644 snippet/getSlug.test.js create mode 100644 snippet/getSsmHookableValue.js create mode 100644 snippet/getSsmHookableValue.test.js create mode 100644 snippet/getSsmHookableValueMonkeyGetSet.js create mode 100644 snippet/getSsmLocalState.js create mode 100644 snippet/getSsmState.js create mode 100644 snippet/getSsmValue.js create mode 100644 snippet/monkeyGetSetValue.js create mode 100644 snippet/monkeySetValue.js create mode 100644 snippet/setSsmValue.js diff --git a/snippet/DescrGen.js b/snippet/DescrGen.js new file mode 100644 index 0000000..52e5fbe --- /dev/null +++ b/snippet/DescrGen.js @@ -0,0 +1,30 @@ +class DescrGen { + constructor(width = 79) { + this._lines = []; + this._width = width; + } + + addLine(line) { + this._lines.push(line); + } + + addCenterLine(line) { + const padding = Math.floor((this._width - line.length) / 2); + const centeredLine = ' '.repeat(padding) + line; + this._lines.push(centeredLine); + } + + addPadRight(field, padSize, padChar, sep, value) { + const paddedField = field + padChar.repeat(padSize - field.length); + const line = paddedField + sep + value; + this._lines.push(line); + } + + addSeparator(char = '-', length = this._width) { + this._lines.push(char.repeat(length)); + } + + generate() { + return this._lines.join('\n'); + } +} diff --git a/snippet/capitalize.js b/snippet/capitalize.js new file mode 100644 index 0000000..338ab57 --- /dev/null +++ b/snippet/capitalize.js @@ -0,0 +1,7 @@ +/** + * Capitalizes the first letter of a string. + * + * @param {string} data The string to capitalize + * @returns {string} The capitalized string + */ +const capitalize = (data) => data.length > 0 ? data[0].toUpperCase() + data.slice(1) : ""; diff --git a/snippet/capitalize.test.js b/snippet/capitalize.test.js new file mode 100644 index 0000000..4e86e19 --- /dev/null +++ b/snippet/capitalize.test.js @@ -0,0 +1,12 @@ +describe('capitalize', () => { + test('should capitalize the first letter of a string', () => { + expect(capitalize('hello')).toBe('Hello'); + expect(capitalize('world')).toBe('World'); + expect(capitalize('javaScript')).toBe('JavaScript'); + expect(capitalize('a')).toBe('A'); + }); + + test('should handle empty strings', () => { + expect(capitalize('')).toBe(''); + }); +}); \ No newline at end of file diff --git a/snippet/copyTextToClipboard.js b/snippet/copyTextToClipboard.js new file mode 100644 index 0000000..78c1680 --- /dev/null +++ b/snippet/copyTextToClipboard.js @@ -0,0 +1,14 @@ +/** + * Copy some text to clipboard + * + * @param {string} text text to copy to clipboard + * @returns + */ +const copyTextToClipboard = async (text) => { + if (!navigator.clipboard) { + console.log(`Can't copy [${test}] : No navigator.clipboard API`) + return; + } + + await navigator.clipboard.writeText(text) +} diff --git a/snippet/getMiBSize.js b/snippet/getMiBSize.js new file mode 100644 index 0000000..de1171b --- /dev/null +++ b/snippet/getMiBSize.js @@ -0,0 +1,9 @@ +/** + * Get the size in Mo from bytes + * + * @param {Number} sizeInBytes The size in bytes + * @returns {string} The size in MiB + */ +const getMiBSize = (sizeInBytes) => { + return `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MiB`; +} \ No newline at end of file diff --git a/snippet/getMiBSize.test.js b/snippet/getMiBSize.test.js new file mode 100644 index 0000000..1df0445 --- /dev/null +++ b/snippet/getMiBSize.test.js @@ -0,0 +1,14 @@ +describe('getMiBSize', () => { + const testExample = (input, expected) => { + test(`should convert ${input} bytes to "${expected}"`, () => { + expect(getMiBSize(input)).toBe(expected); + }); + } + testExample(1048576, '1.00 MiB'); // 1 MB + testExample(5242880, '5.00 MiB'); // 5 MB + testExample(15728640, '15.00 MiB'); // 15 MB + testExample(123456789, '117.74 MiB'); // ~117.74 MB + testExample(0, '0.00 MiB'); // 0 MB + testExample(100000, '0.10 MiB'); // 0.10 MB + testExample(700000, '0.67 MiB'); // 0.67 MB +}); \ No newline at end of file diff --git a/snippet/getSlug.js b/snippet/getSlug.js new file mode 100644 index 0000000..23784f0 --- /dev/null +++ b/snippet/getSlug.js @@ -0,0 +1,20 @@ +/** + * Get a slug from a name removing diacritics and special characters + * @param {string} name The name to convert + * @returns {string} The slugified version of the name + */ +const getSlug = (name, options = {}) => { + let currentName = name; + if (! options.case) { + currentName = currentName.toLocaleLowerCase(); + } + + currentName = currentName.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); // Remove diacritics + currentName = currentName.replace(/ß/g, "ss"); // Special case for German sharp S + currentName = currentName.replace(/[^A-Za-z0-9]/g, '-').replace(/--+/g, '-').replace(/^-+|-+$/g, ''); + + if (options.separator) { + currentName = currentName.replace(/-/g, options.separator); + } + return currentName; +} diff --git a/snippet/getSlug.test.js b/snippet/getSlug.test.js new file mode 100644 index 0000000..76b65db --- /dev/null +++ b/snippet/getSlug.test.js @@ -0,0 +1,36 @@ +describe('getSlug', () => { + const testExample = (input, expected, options) => { + test(`should convert "${input}" (with options: ${JSON.stringify(options)}) to "${expected}"`, () => { + expect(getSlug(input, options)).toBe(expected); + }); + } + testExample('Hello World!', 'hello-world'); + testExample(' Leading and trailing spaces ', 'leading-and-trailing-spaces'); + testExample('Special #$&* Characters', 'special-characters'); + testExample('Multiple Spaces', 'multiple-spaces'); + testExample('UPPERCASE to lowercase', 'uppercase-to-lowercase'); + testExample('Accented éàü Characters', 'accented-eau-characters'); + testExample('Mixed-Separators_and Spaces', 'mixed-separators-and-spaces'); + testExample('', ''); + testExample('ßpecial', 'sspecial'); + + testExample('Hello World!', 'Hello-World', {case: true}); + testExample(' Leading and trailing spaces ', 'Leading-and-trailing-spaces', {case: true}); + testExample('Special #$&* Characters', 'Special-Characters', {case: true}); + testExample('Multiple Spaces', 'Multiple-Spaces', {case: true}); + testExample('UPPERCASE to lowercase', 'UPPERCASE-to-lowercase', {case: true}); + testExample('Accented éàü Characters', 'Accented-eau-Characters', {case: true}); + testExample('Mixed-Separators_and Spaces', 'Mixed-Separators-and-Spaces', {case: true}); + testExample('', ''); + testExample('ßpecial', 'sspecial', {case: true}); + + testExample('Hello World!', 'Hello.World', {case: true, separator: '.'}); + testExample(' Leading and trailing spaces ', 'Leading.and.trailing.spaces', {case: true, separator: '.'}); + testExample('Special #$&* Characters', 'Special.Characters', {case: true, separator: '.'}); + testExample('Multiple Spaces', 'Multiple.Spaces', {case: true, separator: '.'}); + testExample('UPPERCASE to lowercase', 'UPPERCASE.to.lowercase', {case: true, separator: '.'}); + testExample('Accented éàü Characters', 'Accented.eau.Characters', {case: true, separator: '.'}); + testExample('Mixed-Separators_and Spaces', 'Mixed.Separators.and.Spaces', {case: true, separator: '.'}); + testExample('', ''); + testExample('ßpecial', 'sspecial', {case: true}); +}) \ No newline at end of file diff --git a/snippet/getSsmHookableValue.js b/snippet/getSsmHookableValue.js new file mode 100644 index 0000000..669f609 --- /dev/null +++ b/snippet/getSsmHookableValue.js @@ -0,0 +1,15 @@ +// @import{HookableValue} +// @import{getSsmLocalState} +/** + * Gets a SSM hookable value + * @template T + * @param {string} localName The SSM local state name + * @param {string} name The hookable value name + * @param {T|null} defaultValue The default value + * @returns {HookableValue} The SSM hookable value + */ +const getSsmHookableValue = (localName, name, defaultValue = null) => { + const ssmLocalState = getSsmLocalState(localName); + ssmLocalState[name] = ssmLocalState[name] || new HookableValue(name, defaultValue); + return ssmLocalState[name]; +} diff --git a/snippet/getSsmHookableValue.test.js b/snippet/getSsmHookableValue.test.js new file mode 100644 index 0000000..515b5b5 --- /dev/null +++ b/snippet/getSsmHookableValue.test.js @@ -0,0 +1,48 @@ +unsafeWindow = {} + +describe('getSsmHookableValue', () => { + beforeEach(() => { + Object.keys(unsafeWindow).forEach((key)=>{ delete unsafeWindow[key]; }) + }); + + test('should return the same HookableValue instance for the same localName and name', () => { + const hv1 = getSsmHookableValue('local1', 'value1', 42); + const hv2 = getSsmHookableValue('local1', 'value1', 100); + expect(hv1).toBe(hv2); + expect(hv1.value).toBe(42); + }); + + test('should return different HookableValue instances for different names', () => { + const hv1 = getSsmHookableValue('local1', 'value1', 'foo'); + const hv2 = getSsmHookableValue('local1', 'value2', 'bar'); + expect(hv1).not.toBe(hv2); + expect(hv1.value).toBe('foo'); + expect(hv2.value).toBe('bar'); + }); + + test('should return different HookableValue instances for different localNames', () => { + const hv1 = getSsmHookableValue('local1', 'value1', true); + const hv2 = getSsmHookableValue('local2', 'value1', false); + expect(hv1).not.toBe(hv2); + expect(hv1.value).toBe(true); + expect(hv2.value).toBe(false); + }); + + test('should use null as default value if none is provided', () => { + const hv = getSsmHookableValue('local1', 'value1'); + expect(hv.value).toBeNull(); + }); + + test('should execute hook when value changes', () => { + const hv = getSsmHookableValue('local1', 'value1', 10); + let hookCalled = false; + hv.register((newValue, oldValue) => { + hookCalled = true; + expect(oldValue).toBe(10); + expect(newValue).toBe(20); + }); + expect(hookCalled).toBe(false); + hv.value = 20; + expect(hookCalled).toBe(true); + }); +}); \ No newline at end of file diff --git a/snippet/getSsmHookableValueMonkeyGetSet.js b/snippet/getSsmHookableValueMonkeyGetSet.js new file mode 100644 index 0000000..c109494 --- /dev/null +++ b/snippet/getSsmHookableValueMonkeyGetSet.js @@ -0,0 +1,18 @@ +// @import{getSsmHookableValue} +// @import{monkeyGetSetValue} +// @import{monkeySetValue} +/** + * Gets a SSM hookable value with monkey get/set integration + * @template T + * @param {string} localName The SSM local state name + * @param {string} name The hookable value name + * @param {T|null} defaultValue The default value + * @returns {HookableValue} The SSM hookable value + */ +const getSsmHookableValueMonkeyGetSet = (localName, name, defaultValue = null) => { + const hookableValue = getSsmHookableValue(localName, name, monkeyGetSetValue(name, defaultValue)); + hookableValue.register(async (newValue) => { + await monkeySetValue(name, newValue); + }); + return hookableValue; +} diff --git a/snippet/getSsmLocalState.js b/snippet/getSsmLocalState.js new file mode 100644 index 0000000..b313014 --- /dev/null +++ b/snippet/getSsmLocalState.js @@ -0,0 +1,12 @@ +// @import{getSsmState} + +/** + * Gets a local SSM state + * @param {string} localName The local state name + * @returns {Object} The local SSM state + */ +const getSsmLocalState = (localName) => { + const ssmState = getSsmState(); + ssmState[localName] = ssmState[localName] || {}; + return ssmState[localName]; +} diff --git a/snippet/getSsmState.js b/snippet/getSsmState.js new file mode 100644 index 0000000..c601df1 --- /dev/null +++ b/snippet/getSsmState.js @@ -0,0 +1,8 @@ +/** + * Gets the global SSM state + * @returns {Object} The SSM state + */ +const getSsmState = () => { + unsafeWindow.ssmState = unsafeWindow.ssmState || {}; + return unsafeWindow.ssmState; +} diff --git a/snippet/getSsmValue.js b/snippet/getSsmValue.js new file mode 100644 index 0000000..61f1867 --- /dev/null +++ b/snippet/getSsmValue.js @@ -0,0 +1,14 @@ +// @import{getSsmLocalState} +/** + * Gets a SSM const value + * @template T + * @param {string} localName The SSM local state name + * @param {string} name The hookable value name + * @param {()=>T} defaultValueGenerator The const value + * @returns {T} The const value + */ +const getSsmValue = (localName, name, defaultValueGenerator) => { + const ssmLocalState = getSsmLocalState(localName); + ssmLocalState[name] = ssmLocalState[name] || defaultValueGenerator(); + return ssmLocalState[name]; +} diff --git a/snippet/monkeyGetSetValue.js b/snippet/monkeyGetSetValue.js new file mode 100644 index 0000000..95b52b4 --- /dev/null +++ b/snippet/monkeyGetSetValue.js @@ -0,0 +1,17 @@ +// @grant{GM_getValue} +// @grant{GM_setValue} +/** + * Récupère ou définit une valeur dans le stockage de Tampermonkey/Greasemonkey/Violentmonkey/etc. + * + * @param {String} key La clé de la valeur à récupérer ou définir + * @param {String} value La valeur par défaut à définir si la clé n'existe pas (optionnel) + * @returns {String} La valeur récupérée + */ +const monkeyGetSetValue = (key, value) => { + const storedValue = GM_getValue(key); + if (storedValue === undefined && value !== undefined) { + GM_setValue(key, value); + return value; + } + return storedValue; +} diff --git a/snippet/monkeySetValue.js b/snippet/monkeySetValue.js new file mode 100644 index 0000000..22e6557 --- /dev/null +++ b/snippet/monkeySetValue.js @@ -0,0 +1,9 @@ +// @grant{GM_setValue} +/** + * Définit une valeur dans le stockage de Tampermonkey/Greasemonkey/Violentmonkey/etc. + * Juste un alias pour GM_setValue, pour etre cohérent avec monkeyGetSetValue. + * + * @param {String} key La clé de la valeur à définir + * @param {String} value La valeur à définir + */ +const monkeySetValue = (key, value) => GM_setValue(key, value); diff --git a/snippet/setSsmValue.js b/snippet/setSsmValue.js new file mode 100644 index 0000000..0b3db25 --- /dev/null +++ b/snippet/setSsmValue.js @@ -0,0 +1,12 @@ +// @import{getSsmLocalState} +/** + * Sets a SSM const value + * @template T + * @param {string} localName The SSM local state name + * @param {string} name The name of the value + * @param {T|null} value The const value + */ +const setSsmValue = (localName, name, value = null) => { + const ssmLocalState = getSsmLocalState(localName); + ssmLocalState[name] = ssmLocalState[name] || value; +} From a5287e15ac5078a952c293eb6497d65ecbfa546a Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:41:00 +0100 Subject: [PATCH 03/30] More snippets from various sources --- snippet/cleanupString.js | 8 ++++ snippet/cleanupString.test.js | 13 +++++ snippet/counter.js | 12 +++++ snippet/counter.test.js | 24 ++++++++++ snippet/createElementExtended.js | 81 ++++++++++++++++++++++++++++++++ snippet/delay.js | 11 +++++ snippet/downloadData.js | 30 ++++++++++++ snippet/downloadDataUrl.js | 19 ++++++++ snippet/downloadUrl.js | 22 +++++++++ snippet/downloadZip.js | 24 ++++++++++ snippet/onHashChanged.js | 57 ++++++++++++++++++++++ snippet/openLinkInNewTab.js | 15 ++++++ snippet/parentsElement.js | 15 ++++++ snippet/range.js | 39 +++++++++++++++ 14 files changed, 370 insertions(+) create mode 100644 snippet/cleanupString.js create mode 100644 snippet/cleanupString.test.js create mode 100644 snippet/counter.js create mode 100644 snippet/counter.test.js create mode 100644 snippet/createElementExtended.js create mode 100644 snippet/delay.js create mode 100644 snippet/downloadData.js create mode 100644 snippet/downloadDataUrl.js create mode 100644 snippet/downloadUrl.js create mode 100644 snippet/downloadZip.js create mode 100644 snippet/onHashChanged.js create mode 100644 snippet/openLinkInNewTab.js create mode 100644 snippet/parentsElement.js create mode 100644 snippet/range.js diff --git a/snippet/cleanupString.js b/snippet/cleanupString.js new file mode 100644 index 0000000..30bda7b --- /dev/null +++ b/snippet/cleanupString.js @@ -0,0 +1,8 @@ +/** + * Cleanup string by removing leading and trailing whitespaces and replacing multiple whitespaces with a single whitespace + * + * @param {string} string The string to cleanup + * @returns {string} The cleaned string + */ +const cleanupString = (string) => string?.trim()?.replace(/\s+/g, ' ') + diff --git a/snippet/cleanupString.test.js b/snippet/cleanupString.test.js new file mode 100644 index 0000000..176bc0a --- /dev/null +++ b/snippet/cleanupString.test.js @@ -0,0 +1,13 @@ +describe('cleanupString', () => { + test('should cleanup string by removing leading and trailing whitespaces and replacing multiple whitespaces with a single whitespace', () => { + expect(cleanupString(' hello ')).toBe('hello'); + expect(cleanupString(' world ')).toBe('world'); + expect(cleanupString(' hello world ')).toBe('hello world'); + expect(cleanupString(' javaScript ')).toBe('javaScript'); + expect(cleanupString(' a ')).toBe('a'); + }); + + test('should handle empty strings', () => { + expect(cleanupString('')).toBe(''); + }); +}); \ No newline at end of file diff --git a/snippet/counter.js b/snippet/counter.js new file mode 100644 index 0000000..127f332 --- /dev/null +++ b/snippet/counter.js @@ -0,0 +1,12 @@ +/** + * Create a counter generator starting from "start" using a "step" + * @param {number} start + * @param {number} step + */ +const counter = function* (start, step) { + let value = start; + while (true) { + yield value; + value+=step; + } +} diff --git a/snippet/counter.test.js b/snippet/counter.test.js new file mode 100644 index 0000000..236c142 --- /dev/null +++ b/snippet/counter.test.js @@ -0,0 +1,24 @@ +describe('counter', () => { + test('should generate numbers starting from start with step increments', () => { + const c = counter(5, 3); + expect(c.next().value).toBe(5); + expect(c.next().value).toBe(8); + expect(c.next().value).toBe(11); + expect(c.next().value).toBe(14); + }); + + test('should work with negative steps', () => { + const c = counter(10, -2); + expect(c.next().value).toBe(10); + expect(c.next().value).toBe(8); + expect(c.next().value).toBe(6); + expect(c.next().value).toBe(4); + }); + + test('should work with zero step', () => { + const c = counter(7, 0); + expect(c.next().value).toBe(7); + expect(c.next().value).toBe(7); + expect(c.next().value).toBe(7); + }); +}); \ No newline at end of file diff --git a/snippet/createElementExtended.js b/snippet/createElementExtended.js new file mode 100644 index 0000000..c868b54 --- /dev/null +++ b/snippet/createElementExtended.js @@ -0,0 +1,81 @@ +/** + * Create a new element, and add some properties to it + * + * @param {string} name The name of the element to create + * @param {object} params The parameters to tweek the new element + * @param {object.} params.attributes The propeties of the new element + * @param {object.} params.style The style properties of the new element + * @param {string} params.text The textContent of the new element + * @param {HTMLElement[]} params.children The children of the new element + * @param {HTMLElement} params.parent The parent of the new element + * @param {string[]} params.classnames The classnames of the new element + * @param {string} params.id The classnames of the new element + * @param {HTMLElement} params.prevSibling The previous sibling of the new element (to insert after) + * @param {HTMLElement} params.nextSibling The next sibling of the new element (to insert before) + * @param {(element:HTMLElement)=>{}} params.onCreated called when the element is fully created + * @returns {HTMLElement} The created element + */ +const createElementExtended = (name, params) => { + /** @type{HTMLElement} */ + const element = document.createElement(name) + if (!params) { + params = {} + } + const { attributes, text, children, parent, prependIn, classnames, id, style, prevSibling, nextSibling, onCreated } = params + if (attributes) { + for (let attributeName in attributes) { + element.setAttribute(attributeName, attributes[attributeName]) + } + } + if (style) { + for (let key in style) { + element.style[key] = style[key]; + } + } + if (text) { + element.textContent = text; + } + if (children) { + const addChild = (child) => { + if (child) { + if (typeof child === 'string') { + element.appendChild(document.createTextNode(child)) + } else if (Array.isArray(child)) { + for (let subChild of child) { + addChild(subChild) + } + } else { + element.appendChild(child) + } + } + } + + for (let child of children) { + addChild(child) + } + } + if (parent) { + parent.appendChild(element) + } + if (prependIn) { + prependIn.prepend(element) + } + if (classnames) { + for (let classname of classnames) { + element.classList.add(classname) + } + } + if (id) { + element.id = id + } + if (prevSibling) { + prevSibling.parentElement.insertBefore(element, prevSibling.nextSibling) + } + if (nextSibling) { + nextSibling.parentElement.insertBefore(element, nextSibling) + } + if (onCreated) { + onCreated(element) + } + return element +} diff --git a/snippet/delay.js b/snippet/delay.js new file mode 100644 index 0000000..966f14e --- /dev/null +++ b/snippet/delay.js @@ -0,0 +1,11 @@ +/** + * @param {Number} timeout The timeout in ms + * @param {Object} data The data to return after the timeout + * @returns + */ +const delay = (timeout, data) => + new Promise((resolve) => + setTimeout(() => + resolve(data), timeout + ) + ) diff --git a/snippet/downloadData.js b/snippet/downloadData.js new file mode 100644 index 0000000..d0a8a68 --- /dev/null +++ b/snippet/downloadData.js @@ -0,0 +1,30 @@ +// @import{createElementExtended} + +/** + * Download data as a file + * + * @param {string} filename - The name of the file + * @param {string} data - The data to download + * @param {object} options - The options + * @param {string} options.mimetype - The mimetype of the data + * @param {string} options.encoding - The encoding to use on the text data if provided + */ +const downloadData = (filename, data, options) => { + if (!options) { + options = {} + } + const { mimetype, encoding } = options + if (!mimetype) { + mimetype = 'application/octet-stream' + } + if (encoding) { + data = new TextEncoder(encoding).encode(data) + } + const element = createElementExtended('a', { + attributes: { + href: URL.createObjectURL(new Blob([data], { type: mimetype })), + download: filename, + } + }) + element.click() +} diff --git a/snippet/downloadDataUrl.js b/snippet/downloadDataUrl.js new file mode 100644 index 0000000..1138acb --- /dev/null +++ b/snippet/downloadDataUrl.js @@ -0,0 +1,19 @@ +// @import{createElementExtended} + +/** + * Downloads a data url as a file + * + * @param {String} url The data url to download + * @param {String} filename The filename to use to save the file + * @returns + */ +const downloadDataUrl = async (url, filename) => { + const a = createElementExtended('a', { + attributes: { + href: url, + target: '_blank', + download: filename, + }, + }) + a.click() +} \ No newline at end of file diff --git a/snippet/downloadUrl.js b/snippet/downloadUrl.js new file mode 100644 index 0000000..a55b783 --- /dev/null +++ b/snippet/downloadUrl.js @@ -0,0 +1,22 @@ +// @import{createElementExtended} + +/** + * Downloads a url as a file + * + * @param {String} url The url to download + * @param {String} filename The filename to use to save the file + * @returns + */ +const downloadUrl = async (url, filename) => { + const response = await fetch(url); + const blob = await response.blob(); + const urlContent = window.URL.createObjectURL(blob); + const a = createElementExtended('a', { + attributes: { + href: urlContent, + target: '_blank', + download: filename, + }, + }) + a.click() +} \ No newline at end of file diff --git a/snippet/downloadZip.js b/snippet/downloadZip.js new file mode 100644 index 0000000..1e7d148 --- /dev/null +++ b/snippet/downloadZip.js @@ -0,0 +1,24 @@ +// @require{https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js} +/** + * Download a zip file with the given filename and content provided by the async generator + * + * @param {string} fileName The zip filename to download + * @param {() => AsyncGenerator<{path: string, data: Uint8Array}>} contentProvider + */ +const downloadZip = async (fileName, contentProvider) => { + const zip = new JSZip(); + for await (const { path, data, date } of contentProvider()) { + const options = {}; + + if (date) { + options.date = date; + } + + zip.file(path, data, options); + } + const content = await zip.generateAsync({ type: "blob" }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(content); + link.download = fileName; + link.click(); +} diff --git a/snippet/onHashChanged.js b/snippet/onHashChanged.js new file mode 100644 index 0000000..9ec6739 --- /dev/null +++ b/snippet/onHashChanged.js @@ -0,0 +1,57 @@ +const {setHashChanged, setLocationHash, getCurrentLocationHash} = (() => { + /** + * A callback that should be called each time the hash has changed, or the first time the callback is installed + * @type {(hash: string) => void} + */ + let onHashChanged = null; + + /** + * The current hash in the page + * @type {string} + */ + let currentHash = null; + + /** + * Force execute the callback "onHashChanged" if any + */ + const executeHashChanged = () => { + if (onHashChanged) { + onHashChanged(currentHash); + } + } + /** + * Update the current hash with a new value + * @param {string} hash The new value of the hash to set. + */ + const updateCurrentHash = (hash) => { + currentHash = hash; + executeHashChanged(); + } + /** + * Set the location hash, executing the callback only if the new hash is different from the old one. + * @param {string} hash + */ + const setLocationHash = (hash) => { + if (hash !== currentHash) { + location.hash = hash; + updateCurrentHash(location.hash); + } + } + // Install the hash change callback + window.onhashchange = () => { + if (location.hash !== currentHash) { + updateCurrentHash(location.hash); + } + }; + currentHash = location.hash; + /** + * Add a callback when the hash has changed. + * @param {(hash: string) => void} event The callback to call when the hash has changed + */ + const setHashChanged = (event) => { + onHashChanged = event; + executeHashChanged(); + } + const getCurrentLocationHash = () => currentHash; + return {setHashChanged, setLocationHash, getCurrentLocationHash}; +})(); diff --git a/snippet/openLinkInNewTab.js b/snippet/openLinkInNewTab.js new file mode 100644 index 0000000..0cda012 --- /dev/null +++ b/snippet/openLinkInNewTab.js @@ -0,0 +1,15 @@ +// @import{createElementExtended} + +/** + * Open a link in a new tab + * @param {string} url + */ +const openLinkInNewTab = (url) => { + const link = createElementExtended('a', { + attributes: { + href: url, + target: '_blank', + }, + }) + link.click(); +} \ No newline at end of file diff --git a/snippet/parentsElement.js b/snippet/parentsElement.js new file mode 100644 index 0000000..0e24c0e --- /dev/null +++ b/snippet/parentsElement.js @@ -0,0 +1,15 @@ +/** + * Get the depth'th parent of the element + * + * @param {HTMLElement} element + * @param {Number} depth + * @returns {HTMLElement} + */ +const parentsElement = (element, depth) => { + let current_element = element + for (let index = 0; index < depth; index++) { + current_element = current_element?.parentElement + } + return current_element +} + diff --git a/snippet/range.js b/snippet/range.js new file mode 100644 index 0000000..87269c7 --- /dev/null +++ b/snippet/range.js @@ -0,0 +1,39 @@ +/** + * Create a generator to simulate a range of numbers + * @param {number} start The first value of the range + * @param {number?} stop The stop value of the range (if any) + * @param {number?} step The step to use (if any) + * @returns + */ +const range = function* (start, stop, step) { + let cond = null; + if (stop === undefined) { + if (step === undefined) { + stop = start + start = 0 + } else { + cond = () => true; + } + } + if (step === undefined) { + if (start 0) { + cond = (i) => i i>stop; + } else { + return; + } + } + let i=start; + while (step > 0 ? istop ) { + yield i; + i+=step; + } +} From 2a14aa0d9a754354a5a2f4d6b2114be824d8cb6f Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:24:30 +0100 Subject: [PATCH 04/30] Update to latest userscripts-manager --- userscripts-manager | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userscripts-manager b/userscripts-manager index 250e760..a227a8f 160000 --- a/userscripts-manager +++ b/userscripts-manager @@ -1 +1 @@ -Subproject commit 250e760e2f0dce4b83f340f132ad44f875beae37 +Subproject commit a227a8f72ec4e996379026e728bde388bbdf4f73 From 325dc904f373f75682976464caf54ce27ae4afed Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:12:38 +0100 Subject: [PATCH 05/30] FIX : Update bot to take into account the v1.2.x have message in reverse order --- snippet/DescrGen.test.js | 31 +++++++++++++++++++++++++++++++ snippet/computeSha256.js | 20 ++++++++++++++++++++ snippet/downloadData.js | 2 +- src/la-cale/la-cale-bot.user.js | 8 ++++++-- 4 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 snippet/DescrGen.test.js create mode 100644 snippet/computeSha256.js diff --git a/snippet/DescrGen.test.js b/snippet/DescrGen.test.js new file mode 100644 index 0000000..f2620f4 --- /dev/null +++ b/snippet/DescrGen.test.js @@ -0,0 +1,31 @@ +describe('DescrGen', () => { + test('should generate description file from various inputs', () => { + const descrGen = new DescrGen(79); + + descrGen.addSeparator('='); + descrGen.addCenterLine('This is a test description file.'); + descrGen.addSeparator('='); + + descrGen.addLine(''); + descrGen.addLine('Normal line'); + descrGen.addLine(''); + + descrGen.addPadRight('Field1', 20, '.', ': ', 'Value1'); + descrGen.addPadRight('LongerFieldName', 20, '.', ': ', 'Value2'); + + descrGen.addLine(''); + + descrGen.addSeparator(); + + expect(descrGen.generate()).toBe(`=============================================================================== + This is a test description file. +=============================================================================== + +Normal line + +Field1..............: Value1 +LongerFieldName.....: Value2 + +-------------------------------------------------------------------------------`); + }); +}); diff --git a/snippet/computeSha256.js b/snippet/computeSha256.js new file mode 100644 index 0000000..047f8bd --- /dev/null +++ b/snippet/computeSha256.js @@ -0,0 +1,20 @@ +/** + * Compute the SHA-256 hash of the given data. + * + * @param {string|Uint8Array} data The data to hash + * @param {Object|null} options The options + * @param {string} options.encoding The encoding to use on the text data if provided + * @returns {Promise} The SHA-256 hash as a hexadecimal string + */ +const computeSha256 = async (data, options) => { + if (!options) { + options = {} + } + if (options.encoding) { + data = new TextEncoder(options.encoding).encode(data) + } + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashHex; +} diff --git a/snippet/downloadData.js b/snippet/downloadData.js index d0a8a68..b03aeaa 100644 --- a/snippet/downloadData.js +++ b/snippet/downloadData.js @@ -13,7 +13,7 @@ const downloadData = (filename, data, options) => { if (!options) { options = {} } - const { mimetype, encoding } = options + let { mimetype, encoding } = options if (!mimetype) { mimetype = 'application/octet-stream' } diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index 54f96ee..33c313b 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -8,6 +8,7 @@ // @import{registerDomNodeMutatedUnique} // @import{downloadText} // @import{HookableValue} +// @import{computeSha256} class LaCabot { constructor() { @@ -33,7 +34,7 @@ class LaCabot { this._titleZone = titleZone this._contentZone = contentZone this._writeZone = writeZone - registerDomNodeMutatedUnique(() => getSubElements(this._contentZone, 'div.h-full.overflow-y-auto>div'), (line) => { + registerDomNodeMutatedUnique(() => getSubElements(this._contentZone, 'div.h-full.overflow-y-auto>div.gap-3').reverse(), (line) => { // console.log({line}) if (line.children.length == 3) { const props = {} @@ -143,7 +144,10 @@ const addLogManagement = (laCabot) => { const emptyLogs = () => { if (logs.messages.length > 0) { console.log('Log for idRange', logs.idRange, '\n', logs.messages.join('\n')) - downloadText(`la-cale-log-${logs.day}--${logs.idRange.replaceAll(':', '-')}x.txt`, logs.messages.join('\n')) + const content = logs.messages.join('\n') + computeSha256(content, { encoding: 'utf-8' }).then((hash) => { + downloadData(`la-cale-log-${logs.day}--${logs.idRange.replaceAll(':', '-')}x-${hash.slice(0,10)}.txt`, content, { encoding: 'utf-8', mimetype: 'text/plain' }) + }) } logs.messages = [] logs.isEmpty.setValue(true) From b74567a9c8688afee2fe56ded79b583350442fc0 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:17:12 +0100 Subject: [PATCH 06/30] FIX : Fix import downloadText -> downloadData --- src/la-cale/la-cale-bot.user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index 33c313b..7c483eb 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -6,7 +6,7 @@ // @import{getSubElements} // @import{registerDomNodeMutated} // @import{registerDomNodeMutatedUnique} -// @import{downloadText} +// @import{downloadData} // @import{HookableValue} // @import{computeSha256} From e7dcefa7111b8bc3bab5147d06cffd2559209edf Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:32:31 +0100 Subject: [PATCH 07/30] Execute the bot on anypage on la-cale because la-cale is SPA --- src/la-cale/common.props.json | 6 +++ src/la-cale/la-cale-bot.user.js | 82 ++++++++++++++++++++++++--------- 2 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 src/la-cale/common.props.json diff --git a/src/la-cale/common.props.json b/src/la-cale/common.props.json new file mode 100644 index 0000000..693f221 --- /dev/null +++ b/src/la-cale/common.props.json @@ -0,0 +1,6 @@ +{ + "match": [ + "https://la-cale.space/*" + ], + "iconFromDomain" : "la-cale.space" +} \ No newline at end of file diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index 7c483eb..2b60ae4 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -1,7 +1,3 @@ -// ==UserScript== -// @match https://la-cale.space/taverne -// ==/UserScript== - // @import{getElements} // @import{getSubElements} // @import{registerDomNodeMutated} @@ -16,13 +12,15 @@ class LaCabot { this._version = '0.0.1' this._onMessageCallbacks = [] this._onInstalledCallbacks = [] + this._onUninstalledCallbacks = [] this._titleZone = null this._contentZone = null this._writeZone = null + this._toClean = [] } install() { - registerDomNodeMutatedUnique(() => getElements('main>div>div'), (element) => { + this._toClean.push(registerDomNodeMutatedUnique(() => getElements('main>div>div'), (element) => { console.log({ element, ecl: element?.children?.length }) if (element && element.children.length === 3) { console.log({ c: element.children }) @@ -34,7 +32,7 @@ class LaCabot { this._titleZone = titleZone this._contentZone = contentZone this._writeZone = writeZone - registerDomNodeMutatedUnique(() => getSubElements(this._contentZone, 'div.h-full.overflow-y-auto>div.gap-3').reverse(), (line) => { + this._toClean.push(registerDomNodeMutatedUnique(() => getSubElements(this._contentZone, 'div.h-full.overflow-y-auto>div.gap-3').reverse(), (line) => { // console.log({line}) if (line.children.length == 3) { const props = {} @@ -59,26 +57,42 @@ class LaCabot { } return true - }) + })) this._onInstalled(); return true } return false - }) + })) + return () => { + this._onUninstalled() + for (const clean of this._toClean) { + clean() + } + } } registerOnMessage(callback) { this._onMessageCallbacks.push(callback) - return () => { + const result = () => { this._onMessageCallbacks.splice(this._onMessageCallbacks.indexOf(callback), 1) } + return result } registerOnInstalled(callback) { this._onInstalledCallbacks.push(callback) - return () => { + const result = () => { this._onInstalledCallbacks.splice(this._onInstalledCallbacks.indexOf(callback), 1) } + return result + } + + registerOnUninstalled(callback) { + this._onUninstalledCallbacks.push(callback) + const result = () => { + this._onUninstalledCallbacks.splice(this._onUninstalledCallbacks.indexOf(callback), 1) + } + return result } _onMessage(props) { @@ -94,6 +108,12 @@ class LaCabot { } } + _onUninstalled() { + for (const callback of this._onUninstalledCallbacks) { + callback() + } + } + addButton(icon, onClick) { const button = document.createElement('button') button.textContent = icon; @@ -146,7 +166,7 @@ const addLogManagement = (laCabot) => { console.log('Log for idRange', logs.idRange, '\n', logs.messages.join('\n')) const content = logs.messages.join('\n') computeSha256(content, { encoding: 'utf-8' }).then((hash) => { - downloadData(`la-cale-log-${logs.day}--${logs.idRange.replaceAll(':', '-')}x-${hash.slice(0,10)}.txt`, content, { encoding: 'utf-8', mimetype: 'text/plain' }) + downloadData(`la-cale-log-${logs.day}--${logs.idRange.replaceAll(':', '-')}x-${hash.slice(0, 10)}.txt`, content, { encoding: 'utf-8', mimetype: 'text/plain' }) }) } logs.messages = [] @@ -176,21 +196,41 @@ const addLogManagement = (laCabot) => { buttonClean.disabled = newValue; }); }); + + laCabot.registerOnUninstalled(() => { + emptyLogs(); + }); } const main = async () => { - const laCabot = new LaCabot() - laCabot.install() - laCabot.registerOnMessage((props) => { - console.log('New message received:', props) - }) - window.laCabot = laCabot + let lastLocation = null + let unistallLaCabot = null + registerLocationChange((location) => { + if (location.pathname === '/taverne' && location.href !== lastLocation) { + lastLocation = location + if (unistallLaCabot) { + unistallLaCabot() + unistallLaCabot = null + } + const laCabot = new LaCabot() + unistallLaCabot = laCabot.install() + laCabot.registerOnMessage((props) => { + console.log('New message received:', props) + }) + window.laCabot = laCabot - laCabot.registerOnInstalled(() => { - console.log('LaCabot installed') - }); + laCabot.registerOnInstalled(() => { + console.log('LaCabot installed') + }); - addLogManagement(laCabot) + addLogManagement(laCabot) + } else if (location.pathname !== '/taverne' && unistallLaCabot) { + unistallLaCabot() + unistallLaCabot = null + + } + lastLocation = location + }) } main() \ No newline at end of file From 0c37fa79191b214df96f1c9f5ada94832088aa42 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:37:56 +0100 Subject: [PATCH 08/30] FIX : import registerLocationChange --- snippet/registerLocationChange.js | 37 +++++++++++++++++++++++++++++++ src/la-cale/la-cale-bot.user.js | 1 + 2 files changed, 38 insertions(+) create mode 100644 snippet/registerLocationChange.js diff --git a/snippet/registerLocationChange.js b/snippet/registerLocationChange.js new file mode 100644 index 0000000..fdf092d --- /dev/null +++ b/snippet/registerLocationChange.js @@ -0,0 +1,37 @@ +/** + * Registers a callback to be called when the location changes (SPA navigation) + * + * @param {(Location)=>void} callback A callback called when the location changes + * @returns {()=>void} The unregister function + */ +const registerLocationChange = (callback) => { + const normalizeLocation = (location) => { + const { href, origin, protocol, host, hostname, port, pathname, search, hash } = location; + const pathParts = pathname.split('/') + if (pathParts.length > 0 && pathParts[0] === '') { + pathParts.shift(); + } + const isFolder = pathParts.length === 0 || pathParts[pathParts.length - 1] === ''; + if (isFolder) { + pathParts.pop(); + } + + return { href, origin, protocol, host, hostname, port, pathname, pathParts, isFolder, search, hash }; + } + let currentLocation = normalizeLocation(location); + + const observer = new MutationObserver(() => { + const newLocation = normalizeLocation(location); + if (newLocation.href !== currentLocation.href) { + currentLocation = newLocation; + callback(currentLocation); + } + }); + + callback(currentLocation); + observer.observe(document, { subtree: true, childList: true }); + + return () => { + observer.disconnect(); + }; +} diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index 2b60ae4..31e3024 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -5,6 +5,7 @@ // @import{downloadData} // @import{HookableValue} // @import{computeSha256} +// @import{registerLocationChange} class LaCabot { constructor() { From 2e390c76d780e74d6e685a557742503fda2169c3 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:45:18 +0100 Subject: [PATCH 09/30] FIX : Try to fix incorrect idRange in filename for old lof range --- src/la-cale/la-cale-bot.user.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index 31e3024..873a24c 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -164,10 +164,10 @@ const addLogManagement = (laCabot) => { const emptyLogs = () => { if (logs.messages.length > 0) { - console.log('Log for idRange', logs.idRange, '\n', logs.messages.join('\n')) - const content = logs.messages.join('\n') + const content = logs.messages.map(m => m.messageLog).join('\n') + console.log('Log for idRange', logs.idRange, '\n', content) computeSha256(content, { encoding: 'utf-8' }).then((hash) => { - downloadData(`la-cale-log-${logs.day}--${logs.idRange.replaceAll(':', '-')}x-${hash.slice(0, 10)}.txt`, content, { encoding: 'utf-8', mimetype: 'text/plain' }) + downloadData(`la-cale-log-${logs.day}--${logs.messages[0].idRange.replaceAll(':', '-')}x-${hash.slice(0, 10)}.txt`, content, { encoding: 'utf-8', mimetype: 'text/plain' }) }) } logs.messages = [] @@ -182,7 +182,8 @@ const addLogManagement = (laCabot) => { logs.day = props.clientDate.slice(0, 10) logs.idRange = idRange } - logs.messages.push(`[${props.serverDate}] ${props.iconGrade}${props.user}: ${props.message}`) + const messageLog = `[${props.serverDate}] ${props.iconGrade}${props.user}: ${props.message}` + logs.messages.push({...props, messageLog}) logs.isEmpty.setValue(false) }) From 2e67e1b1e22930323fbc574a24a8ade8756395e0 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:49:35 +0100 Subject: [PATCH 10/30] Provide idRange on message structure --- src/la-cale/la-cale-bot.user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index 873a24c..949ef75 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -183,7 +183,7 @@ const addLogManagement = (laCabot) => { logs.idRange = idRange } const messageLog = `[${props.serverDate}] ${props.iconGrade}${props.user}: ${props.message}` - logs.messages.push({...props, messageLog}) + logs.messages.push({...props, idRange, messageLog}) logs.isEmpty.setValue(false) }) From 3c0fa2e7e2fd9a83c3a52bf834ad4212c695469d Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:05:24 +0100 Subject: [PATCH 11/30] Take into account referenced messages --- src/la-cale/la-cale-bot.user.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index 949ef75..4a72d8b 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -49,9 +49,14 @@ class LaCabot { props.serverDate = userElements[2].textContent props.clientDate = new Date().toISOString() } - if (messageElement) { - props.message = messageElement.textContent - props.messageElement = messageElement + for (messageElement in [...line.children[1].children].slice(1)) { + if (messageElement && messageElement.classList.contains('text-text-primary-medium')) { + props.message = messageElement.textContent + props.messageElement = messageElement + } + if (messageElement && messageElement.classList.contains('text-text-primary-muted')) { + props.reference = messageElement.textContent + } } } this._onMessage(props) @@ -182,7 +187,10 @@ const addLogManagement = (laCabot) => { logs.day = props.clientDate.slice(0, 10) logs.idRange = idRange } - const messageLog = `[${props.serverDate}] ${props.iconGrade}${props.user}: ${props.message}` + let messageLog = `[${props.serverDate}] ${props.iconGrade}${props.user}: ${props.message}` + if (props.reference) { + messageLog += ` (> ${props.reference})` + } logs.messages.push({...props, idRange, messageLog}) logs.isEmpty.setValue(false) }) From 12a96c005d9dbdc137d95ce8653114e76d57ba09 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:36:52 +0100 Subject: [PATCH 12/30] FIX : Fix some asynchronous problems --- src/la-cale/la-cale-bot.user.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index 4a72d8b..6c00adc 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -49,7 +49,7 @@ class LaCabot { props.serverDate = userElements[2].textContent props.clientDate = new Date().toISOString() } - for (messageElement in [...line.children[1].children].slice(1)) { + for (let messageElement of [...line.children[1].children].slice(1)) { if (messageElement && messageElement.classList.contains('text-text-primary-medium')) { props.message = messageElement.textContent props.messageElement = messageElement @@ -171,8 +171,11 @@ const addLogManagement = (laCabot) => { if (logs.messages.length > 0) { const content = logs.messages.map(m => m.messageLog).join('\n') console.log('Log for idRange', logs.idRange, '\n', content) + const idRangeFilename = logs.messages[0].idRange.replaceAll(':', '-') + const dayFilename = logs.day computeSha256(content, { encoding: 'utf-8' }).then((hash) => { - downloadData(`la-cale-log-${logs.day}--${logs.messages[0].idRange.replaceAll(':', '-')}x-${hash.slice(0, 10)}.txt`, content, { encoding: 'utf-8', mimetype: 'text/plain' }) + const filename = `la-cale-log-${dayFilename}--${idRangeFilename}x-${hash.slice(0, 10)}.txt` + downloadData(filename, content, { encoding: 'utf-8', mimetype: 'text/plain' }) }) } logs.messages = [] From c7cd94029ad56aa252bde7dd017b105e755ea5fb Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:33:50 +0100 Subject: [PATCH 13/30] Add an easy upload userscript --- snippet/registerEventListener.js | 24 +++ src/la-cale/la-cale-easy-upload.user.js | 193 ++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 snippet/registerEventListener.js create mode 100644 src/la-cale/la-cale-easy-upload.user.js diff --git a/snippet/registerEventListener.js b/snippet/registerEventListener.js new file mode 100644 index 0000000..cb2ca26 --- /dev/null +++ b/snippet/registerEventListener.js @@ -0,0 +1,24 @@ +/** + * Wrap addEventListener and removeEventListener using a pattern where the unregister function is returned + * + * @param {HTMLElement|EventTarget} element The object on which to register the event + * @param {string} eventType The event type + * @param {EventListenerOrEventListenerObject} callback The callback to call when the event is triggered + * @param {boolean|AddEventListenerOptions=} options The options to pass to addEventListener + * @return {()=>{}} The unregister function + */ +const registerEventListener = (element, eventType, callback, options) => { + if (element.addEventListener) { + element.addEventListener(eventType, callback, options); + if (typeof options === 'object' && !Array.isArray(options) && options !== null) { + if (options.executeAtRegister) { + setTimeout(()=>callback(),0) + } + } + } + return () => { + if (element.removeEventListener) { + element.removeEventListener(eventType, callback, options); + } + } +} diff --git a/src/la-cale/la-cale-easy-upload.user.js b/src/la-cale/la-cale-easy-upload.user.js new file mode 100644 index 0000000..4c8f789 --- /dev/null +++ b/src/la-cale/la-cale-easy-upload.user.js @@ -0,0 +1,193 @@ +// @import{registerLocationChange} +// @import{getSubElements} +// @import{getElements} +// @import{registerEventListener} +// @import{addStyle} +// @import{delay} +// @import{registerDomNodeMutatedUnique} + +const setReactElementProperty = (element, eventName, propertyName, value) => { + const nativeElementPropertySetter = + Object.getOwnPropertyDescriptor( + element.__proto__, + propertyName + ).set; + + nativeElementPropertySetter.call(element, value); + + element.dispatchEvent(new Event(eventName, { bubbles: true })); +} +const setReactInputValue = (input, value) => setReactElementProperty(input, 'input', 'value', value) +const setReactTextareaValue = (textarea, value) => setReactElementProperty(textarea, 'input', 'value', value) +const setReactInputFiles = (input, files) => setReactElementProperty(input, 'change', 'files', files) + +const fileToFileList = (file) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + return dataTransfer.files; +} + +const uninstallers = []; + +const addUninstaller = (uninstaller) => { + uninstallers.push(uninstaller); +} + +const uninstallAll = () => { + uninstallers.forEach((uninstaller) => uninstaller()); + uninstallers.length = 0; +} + +const getPrezContent = async (prezButton, prezZone) => { + let prezTextarea = getSubElements(prezZone, 'textarea')[0] + if (!prezTextarea) { + prezButton.click(); + await delay(0); + prezTextarea = getSubElements(prezZone, 'textarea')[0] + if (!prezTextarea) { + throw new Error(`Impossible de trouver la textarea de présentation`); + } + } + return prezTextarea; +} + +const showPrezContent = async (prezButton, prezZone) => { + let prezTextarea = getSubElements(prezZone, 'textarea')[0] + if (prezTextarea) { + prezButton.click(); + await delay(0); + } +} + +const installEasyUpload = async () => { + const dropElements = getElements('.p-6.pt-6'); + if (dropElements.length !== 2) { + throw new Error(`Impossible de trouver les éléments de drop (trouvé ${dropElements.length} éléments, attendu 2)`); + } + const dropElement = dropElements[0]; + + const spacey2s = getElements('div.space-y-2'); + if (spacey2s.length !== 8) { + throw new Error(`Impossible de trouver les éléments de space (trouvé ${spacey2s.length} éléments, attendu 8)`); + } + const quaiZone = spacey2s[2] + const emplacementZone = spacey2s[3] + const titreZone = spacey2s[6] + const prezZone = spacey2s[7] + + console.log({ quaiZone, emplacementZone, titreZone, prezZone }) + + const prezButton = getSubElements(prezZone, 'button')[0] + + if (!prezButton) { + throw new Error(`Impossible de trouver le bouton de présentation`); + } + + let prezTextarea = await getPrezContent(prezButton, prezZone); + + const titreInput = getSubElements(titreZone, 'input')[0] + + console.log({ prezButton, prezTextarea, titreInput }) + + const inputFiles = [...document.querySelectorAll('input[type=file]')] + if (inputFiles.length !== 2) { + throw new Error(`Impossible de trouver les champs de fichier (trouvé ${inputFiles.length} éléments, attendu 2)`); + } + const [inputFileTorrent, inputFileNfo] = inputFiles; + + const eventNames = ['dragenter', 'dragover', 'dragleave', 'drop']; + const actionsByEventName = { + dragenter: (element, eventName) => element.classList.add('x-dragover'), + dragover: (element, eventName) => element.classList.add('x-dragover'), + dragleave: (element, eventName) => element.classList.remove('x-dragover'), + drop: async (element, eventName, event) => { + element.classList.remove('x-dragover') + if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) { + const fileCount = event.dataTransfer.files.length; + console.log(`Fichiers déposés : ${fileCount}`); + let prezFile = null; + let tagsFile = null; + for (let i = 0; i < fileCount; i++) { + console.log(`Fichiers déposés : ${fileCount} (${i + 1}/${fileCount})`); + const filename = event.dataTransfer.files[i].name; + console.log(`Fichier déposé : ${filename}`); + if (filename.endsWith('.nfo')) { + setReactInputFiles(inputFileNfo, fileToFileList(event.dataTransfer.files[i])); + } else if (filename.endsWith('.torrent')) { + setReactInputFiles(inputFileTorrent, fileToFileList(event.dataTransfer.files[i])); + } else if (filename.endsWith('.prez')) { + prezFile = event.dataTransfer.files[i]; + const basename = filename.substring(0, filename.length - '.prez'.length); + setReactInputValue(titreInput, basename); + } else if (filename.endsWith('.tags')) { + tagsFile = event.dataTransfer.files[i]; + } + } + if (prezFile !== null) { + const prezContent = await prezFile.text(); + prezTextarea = await getPrezContent(prezButton, prezZone); + setReactTextareaValue(prezTextarea, prezContent); + await showPrezContent(prezButton, prezZone); + } + if (tagsFile !== null) { + const tagsContent = await tagsFile.text(); + console.log(`Contenu du fichier .tags : ${tagsContent}`); + const tags = tagsContent.split('\n').map(tag => tag.trim()) + console.log({ tags }); + + if (tags.length > 2) { + getSubElements(quaiZone, 'button')[0].click() + await delay(0) + let options = getElements('[data-radix-popper-content-wrapper] [role=option]') + options.filter(x => x.textContent === tags[0])[0].dispatchEvent(new Event('click', { bubbles: true })) + await delay(0) + getSubElements(emplacementZone, 'button')[0].click() + await delay(0) + options = getElements('[data-radix-popper-content-wrapper] [role=option]') + options.filter(x => x.textContent === tags[1])[0].dispatchEvent(new Event('click', { bubbles: true })) + await delay(0) + const groupButtons = getElements('.group.cursor-pointer') + groupButtons.filter(x => tags.slice(2).map(x => x.toLocaleUpperCase()).indexOf(x.textContent.toLocaleUpperCase()) >= 0).forEach(x => x.click()) + } + + // quaiZone.querySelectorAll('button')[0].click() + // opts = [...[...document.querySelectorAll('[data-radix-popper-content-wrapper]')][0].querySelectorAll('[role=option]')] + // opts.filter(x=>x.textContent === 'E-books')[0].dispatchEvent(new Event('click',{bubbles:true})) + // emplacementZone.querySelectorAll('button')[0].click() + // opts = [...[...document.querySelectorAll('[data-radix-popper-content-wrapper]')][0].querySelectorAll('[role=option]')] + // opts.filter(x=>x.textContent === 'Presse')[0].dispatchEvent(new Event('click',{bubbles:true})) + // [...document.querySelectorAll('.group.cursor-pointer')].filter(x=>['Actualités', 'Journaux','Français','CBZ'].indexOf(x.textContent)>=0).forEach(x=>x.click()) + } + } + }, + } + + eventNames.forEach((eventName) => { + const unreg = registerEventListener(dropElement, eventName, (event) => { + // console.log(`Event ${eventName} déclenché`); + event.preventDefault(); + event.stopPropagation(); + actionsByEventName[eventName](dropElement, eventName, event); + }); + addUninstaller(unreg); + }); + +} + +const main = () => { + addStyle(`.x-dragover { background-color: rgba(255, 0, 0, 0.3) !important; }`); + registerLocationChange((currentLocation) => { + if (currentLocation.pathname === '/upload') { + try { + installEasyUpload(); + } catch (error) { + alert(`Une erreur est survenue : ${error.message}`); + } + + } else { + uninstallAll(); + } + }); +} + +main() From 445e173a22ed61018a19fe282b19b7d80edc3e12 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:05:57 +0100 Subject: [PATCH 14/30] Fix prezButton for V1.2.4 --- src/la-cale/la-cale-easy-upload.user.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/la-cale/la-cale-easy-upload.user.js b/src/la-cale/la-cale-easy-upload.user.js index 4c8f789..af45422 100644 --- a/src/la-cale/la-cale-easy-upload.user.js +++ b/src/la-cale/la-cale-easy-upload.user.js @@ -77,7 +77,11 @@ const installEasyUpload = async () => { console.log({ quaiZone, emplacementZone, titreZone, prezZone }) - const prezButton = getSubElements(prezZone, 'button')[0] + const prezButtons = getSubElements(prezZone, '.flex.items-center.gap-1 > button') + if (prezButtons.length !== 4) { + throw new Error(`Impossible de trouver les boutons de prez (trouvé ${prezButtons.length} éléments, attendu 4)`); + } + const prezButton = prezButtons[1] if (!prezButton) { throw new Error(`Impossible de trouver le bouton de présentation`); From 32c9b1e72174439cc80fe9fe0f490ffb4cd2d49c Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:41:51 +0100 Subject: [PATCH 15/30] FIX : Fix some delay bug when finding textarea introduced by V1.2.4 --- src/la-cale/la-cale-easy-upload.user.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/la-cale/la-cale-easy-upload.user.js b/src/la-cale/la-cale-easy-upload.user.js index af45422..4f5db0f 100644 --- a/src/la-cale/la-cale-easy-upload.user.js +++ b/src/la-cale/la-cale-easy-upload.user.js @@ -39,8 +39,11 @@ const uninstallAll = () => { } const getPrezContent = async (prezButton, prezZone) => { + prezButton.click(); + await delay(0) let prezTextarea = getSubElements(prezZone, 'textarea')[0] if (!prezTextarea) { + await delay(1000); prezButton.click(); await delay(0); prezTextarea = getSubElements(prezZone, 'textarea')[0] @@ -87,6 +90,12 @@ const installEasyUpload = async () => { throw new Error(`Impossible de trouver le bouton de présentation`); } + const prezShowContentButton = prezButtons[3] + + if (!prezShowContentButton) { + throw new Error(`Impossible de trouver le bouton d'affichage de la présentation`); + } + let prezTextarea = await getPrezContent(prezButton, prezZone); const titreInput = getSubElements(titreZone, 'input')[0] @@ -131,7 +140,7 @@ const installEasyUpload = async () => { const prezContent = await prezFile.text(); prezTextarea = await getPrezContent(prezButton, prezZone); setReactTextareaValue(prezTextarea, prezContent); - await showPrezContent(prezButton, prezZone); + await showPrezContent(prezShowContentButton, prezZone); } if (tagsFile !== null) { const tagsContent = await tagsFile.text(); @@ -153,14 +162,6 @@ const installEasyUpload = async () => { const groupButtons = getElements('.group.cursor-pointer') groupButtons.filter(x => tags.slice(2).map(x => x.toLocaleUpperCase()).indexOf(x.textContent.toLocaleUpperCase()) >= 0).forEach(x => x.click()) } - - // quaiZone.querySelectorAll('button')[0].click() - // opts = [...[...document.querySelectorAll('[data-radix-popper-content-wrapper]')][0].querySelectorAll('[role=option]')] - // opts.filter(x=>x.textContent === 'E-books')[0].dispatchEvent(new Event('click',{bubbles:true})) - // emplacementZone.querySelectorAll('button')[0].click() - // opts = [...[...document.querySelectorAll('[data-radix-popper-content-wrapper]')][0].querySelectorAll('[role=option]')] - // opts.filter(x=>x.textContent === 'Presse')[0].dispatchEvent(new Event('click',{bubbles:true})) - // [...document.querySelectorAll('.group.cursor-pointer')].filter(x=>['Actualités', 'Journaux','Français','CBZ'].indexOf(x.textContent)>=0).forEach(x=>x.click()) } } }, From 786908c01b683e38973eadef8ce7fce1408ffb30 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:46:12 +0100 Subject: [PATCH 16/30] Disable all tags if already set --- src/la-cale/la-cale-easy-upload.user.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/la-cale/la-cale-easy-upload.user.js b/src/la-cale/la-cale-easy-upload.user.js index 4f5db0f..b96f10b 100644 --- a/src/la-cale/la-cale-easy-upload.user.js +++ b/src/la-cale/la-cale-easy-upload.user.js @@ -159,6 +159,8 @@ const installEasyUpload = async () => { options = getElements('[data-radix-popper-content-wrapper] [role=option]') options.filter(x => x.textContent === tags[1])[0].dispatchEvent(new Event('click', { bubbles: true })) await delay(0) + const groupButtonsToDisable = getElements('.group.cursor-pointer.border-brand-primary') + groupButtonsToDisable.forEach(x => x.click()) const groupButtons = getElements('.group.cursor-pointer') groupButtons.filter(x => tags.slice(2).map(x => x.toLocaleUpperCase()).indexOf(x.textContent.toLocaleUpperCase()) >= 0).forEach(x => x.click()) } From 975b97367ad6250f10fb2beee876fa7529578896 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:52:15 +0100 Subject: [PATCH 17/30] Add style to remove animated elements --- src/la-cale/la-cale-no-animated-message.user.css | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/la-cale/la-cale-no-animated-message.user.css diff --git a/src/la-cale/la-cale-no-animated-message.user.css b/src/la-cale/la-cale-no-animated-message.user.css new file mode 100644 index 0000000..02debfb --- /dev/null +++ b/src/la-cale/la-cale-no-animated-message.user.css @@ -0,0 +1,5 @@ +@-moz-document regexp("^https://(www.)?la-cale.space.*") { + .animate-in { + display: none; + } +} From ac03314b9c6e9bc24ee44d3842b413582b161153 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:59:56 +0100 Subject: [PATCH 18/30] FIX : select only the first level --- src/la-cale/la-cale-no-animated-message.user.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/la-cale/la-cale-no-animated-message.user.css b/src/la-cale/la-cale-no-animated-message.user.css index 02debfb..cf92cad 100644 --- a/src/la-cale/la-cale-no-animated-message.user.css +++ b/src/la-cale/la-cale-no-animated-message.user.css @@ -1,5 +1,5 @@ @-moz-document regexp("^https://(www.)?la-cale.space.*") { - .animate-in { + body>div.animate-in { display: none; } } From 962a31c651a96ffcff3b5e331027b02f077f6756 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:01:22 +0100 Subject: [PATCH 19/30] Send index to callcack in registerDomNodeMutatedUnique --- snippet/registerDomNodeMutatedUnique.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/snippet/registerDomNodeMutatedUnique.js b/snippet/registerDomNodeMutatedUnique.js index 3b6fd88..7ce47a8 100644 --- a/snippet/registerDomNodeMutatedUnique.js +++ b/snippet/registerDomNodeMutatedUnique.js @@ -7,20 +7,22 @@ * Ensure that when an element matching the query elementProvider, the callback is called with the element * exactly once for each element * @param {()=>[HTMLElement]} elementProvider - * @param {(element: HTMLElement)=>{}} callback + * @param {(element: HTMLElement, index: number)=>{}} callback */ const registerDomNodeMutatedUnique = (elementProvider, callback) => { const domNodesHandled = new Set() return registerDomNodeMutated(() => { + let index = 0 for (let element of elementProvider()) { if (!domNodesHandled.has(element)) { domNodesHandled.add(element) - const result = callback(element) + const result = callback(element, index) if (result === false) { domNodesHandled.delete(element) } } + index++; } }) } From 8d3024cf184e903b50621f07169d1dcba5aa7735 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:51:35 +0100 Subject: [PATCH 20/30] FIX : Fix dropElement not present at url change --- snippet/RegistrationManager.js | 40 +++++ snippet/registerDomNodeMutatedUnique.js | 26 ++- src/la-cale/la-cale-easy-upload.user.js | 225 ++++++++++++------------ 3 files changed, 167 insertions(+), 124 deletions(-) create mode 100644 snippet/RegistrationManager.js diff --git a/snippet/RegistrationManager.js b/snippet/RegistrationManager.js new file mode 100644 index 0000000..da4dd6c --- /dev/null +++ b/snippet/RegistrationManager.js @@ -0,0 +1,40 @@ +/** + * A simple class to manage the registration and cleanup of the different event listeners and mutations observers using the register pattern. + * + * This class is useful when several registrations need to be done at different times and cleaned up together. + */ +class RegistrationManager { + /** + * Create a new RegistrationManager instance. + * + * @param {Objet} options additional options + * @param {boolean} [options.autoCleanupOnAfterFirstCleanup=false] automatically call cleanup after the first call of cleanupAll (This instance of RegistrationManager will be only used once, but any late registration will be automatically cleaned up) + */ + constructor(options) { + this.cleanupFunctions = [] + this.options = options || {} + this.autoCleanupOnAfterFirstCleanup = this.options.autoCleanupOnAfterFirstCleanup || false + this.hasBeenCleanedUp = false + } + + /** + * Add a new cleanup function + * @param {() => void} cleanupFunction + */ + onRegistration(cleanupFunction) { + if (this.autoCleanupOnAfterFirstCleanup && this.hasBeenCleanedUp) { + cleanupFunction() + } else { + this.cleanupFunctions.push(cleanupFunction) + } + } + + /** + * Cleanup all the cleanup functions. + */ + cleanupAll() { + this.hasBeenCleanedUp = true + this.cleanupFunctions.forEach(cleanup => cleanup()) + this.cleanupFunctions.length = 0 + } +} diff --git a/snippet/registerDomNodeMutatedUnique.js b/snippet/registerDomNodeMutatedUnique.js index 7ce47a8..929d1ea 100644 --- a/snippet/registerDomNodeMutatedUnique.js +++ b/snippet/registerDomNodeMutatedUnique.js @@ -7,22 +7,34 @@ * Ensure that when an element matching the query elementProvider, the callback is called with the element * exactly once for each element * @param {()=>[HTMLElement]} elementProvider - * @param {(element: HTMLElement, index: number)=>{}} callback + * @param {(element: HTMLElement, options: {currentIteration: number, indexElement: number})=>{}} callback + * @param {(element: HTMLElement, options: {currentIteration: number})=>{}} [callbackOnNotHere] called when an element is not here anymore (not provided by the elementProvider anymore) */ -const registerDomNodeMutatedUnique = (elementProvider, callback) => { - const domNodesHandled = new Set() +const registerDomNodeMutatedUnique = (elementProvider, callback, callbackOnNotHere) => { + const domNodesHandled = new Map() + let indexIteration = 0 return registerDomNodeMutated(() => { - let index = 0 + indexIteration++; + let currentIteration = indexIteration + let indexElement = 0 for (let element of elementProvider()) { if (!domNodesHandled.has(element)) { - domNodesHandled.add(element) - const result = callback(element, index) + domNodesHandled.set(element, { element, indexIteration: currentIteration }) + const result = callback(element, { currentIteration, indexElement }) if (result === false) { domNodesHandled.delete(element) } + } else { + domNodesHandled.get(element).indexIteration = currentIteration } - index++; + indexElement++; + } + for (let item of domNodesHandled.values().filter(item => item.indexIteration !== currentIteration)) { + if (callbackOnNotHere) { + callbackOnNotHere(item.element, { currentIteration }) + } + domNodesHandled.delete(item.element) } }) } diff --git a/src/la-cale/la-cale-easy-upload.user.js b/src/la-cale/la-cale-easy-upload.user.js index b96f10b..2bfc572 100644 --- a/src/la-cale/la-cale-easy-upload.user.js +++ b/src/la-cale/la-cale-easy-upload.user.js @@ -5,6 +5,7 @@ // @import{addStyle} // @import{delay} // @import{registerDomNodeMutatedUnique} +// @import{RegistrationManager} const setReactElementProperty = (element, eventName, propertyName, value) => { const nativeElementPropertySetter = @@ -27,17 +28,6 @@ const fileToFileList = (file) => { return dataTransfer.files; } -const uninstallers = []; - -const addUninstaller = (uninstaller) => { - uninstallers.push(uninstaller); -} - -const uninstallAll = () => { - uninstallers.forEach((uninstaller) => uninstaller()); - uninstallers.length = 0; -} - const getPrezContent = async (prezButton, prezZone) => { prezButton.click(); await delay(0) @@ -62,123 +52,125 @@ const showPrezContent = async (prezButton, prezZone) => { } } -const installEasyUpload = async () => { - const dropElements = getElements('.p-6.pt-6'); - if (dropElements.length !== 2) { - throw new Error(`Impossible de trouver les éléments de drop (trouvé ${dropElements.length} éléments, attendu 2)`); - } - const dropElement = dropElements[0]; +const registrationManager = new RegistrationManager() - const spacey2s = getElements('div.space-y-2'); - if (spacey2s.length !== 8) { - throw new Error(`Impossible de trouver les éléments de space (trouvé ${spacey2s.length} éléments, attendu 8)`); - } - const quaiZone = spacey2s[2] - const emplacementZone = spacey2s[3] - const titreZone = spacey2s[6] - const prezZone = spacey2s[7] +const installEasyUpload = async () => { + registerDomNodeMutatedUnique( + () => getElements('.p-6.pt-6'), + async (dropElement, { indexElement }) => { + if (indexElement === 0) { + const spacey2s = getElements('div.space-y-2'); + if (spacey2s.length !== 8) { + throw new Error(`Impossible de trouver les éléments de space (trouvé ${spacey2s.length} éléments, attendu 8)`); + } + const quaiZone = spacey2s[2] + const emplacementZone = spacey2s[3] + const titreZone = spacey2s[6] + const prezZone = spacey2s[7] - console.log({ quaiZone, emplacementZone, titreZone, prezZone }) + console.log({ quaiZone, emplacementZone, titreZone, prezZone }) - const prezButtons = getSubElements(prezZone, '.flex.items-center.gap-1 > button') - if (prezButtons.length !== 4) { - throw new Error(`Impossible de trouver les boutons de prez (trouvé ${prezButtons.length} éléments, attendu 4)`); - } - const prezButton = prezButtons[1] + const prezButtons = getSubElements(prezZone, '.flex.items-center.gap-1 > button') + if (prezButtons.length !== 4) { + throw new Error(`Impossible de trouver les boutons de prez (trouvé ${prezButtons.length} éléments, attendu 4)`); + } + const prezButton = prezButtons[1] - if (!prezButton) { - throw new Error(`Impossible de trouver le bouton de présentation`); - } + if (!prezButton) { + throw new Error(`Impossible de trouver le bouton de présentation`); + } - const prezShowContentButton = prezButtons[3] + const prezShowContentButton = prezButtons[3] - if (!prezShowContentButton) { - throw new Error(`Impossible de trouver le bouton d'affichage de la présentation`); - } + if (!prezShowContentButton) { + throw new Error(`Impossible de trouver le bouton d'affichage de la présentation`); + } - let prezTextarea = await getPrezContent(prezButton, prezZone); + let prezTextarea = await getPrezContent(prezButton, prezZone); - const titreInput = getSubElements(titreZone, 'input')[0] + const titreInput = getSubElements(titreZone, 'input')[0] - console.log({ prezButton, prezTextarea, titreInput }) + console.log({ prezButton, prezTextarea, titreInput }) - const inputFiles = [...document.querySelectorAll('input[type=file]')] - if (inputFiles.length !== 2) { - throw new Error(`Impossible de trouver les champs de fichier (trouvé ${inputFiles.length} éléments, attendu 2)`); - } - const [inputFileTorrent, inputFileNfo] = inputFiles; - - const eventNames = ['dragenter', 'dragover', 'dragleave', 'drop']; - const actionsByEventName = { - dragenter: (element, eventName) => element.classList.add('x-dragover'), - dragover: (element, eventName) => element.classList.add('x-dragover'), - dragleave: (element, eventName) => element.classList.remove('x-dragover'), - drop: async (element, eventName, event) => { - element.classList.remove('x-dragover') - if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) { - const fileCount = event.dataTransfer.files.length; - console.log(`Fichiers déposés : ${fileCount}`); - let prezFile = null; - let tagsFile = null; - for (let i = 0; i < fileCount; i++) { - console.log(`Fichiers déposés : ${fileCount} (${i + 1}/${fileCount})`); - const filename = event.dataTransfer.files[i].name; - console.log(`Fichier déposé : ${filename}`); - if (filename.endsWith('.nfo')) { - setReactInputFiles(inputFileNfo, fileToFileList(event.dataTransfer.files[i])); - } else if (filename.endsWith('.torrent')) { - setReactInputFiles(inputFileTorrent, fileToFileList(event.dataTransfer.files[i])); - } else if (filename.endsWith('.prez')) { - prezFile = event.dataTransfer.files[i]; - const basename = filename.substring(0, filename.length - '.prez'.length); - setReactInputValue(titreInput, basename); - } else if (filename.endsWith('.tags')) { - tagsFile = event.dataTransfer.files[i]; - } - } - if (prezFile !== null) { - const prezContent = await prezFile.text(); - prezTextarea = await getPrezContent(prezButton, prezZone); - setReactTextareaValue(prezTextarea, prezContent); - await showPrezContent(prezShowContentButton, prezZone); + const inputFiles = [...document.querySelectorAll('input[type=file]')] + if (inputFiles.length !== 2) { + throw new Error(`Impossible de trouver les champs de fichier (trouvé ${inputFiles.length} éléments, attendu 2)`); } - if (tagsFile !== null) { - const tagsContent = await tagsFile.text(); - console.log(`Contenu du fichier .tags : ${tagsContent}`); - const tags = tagsContent.split('\n').map(tag => tag.trim()) - console.log({ tags }); - - if (tags.length > 2) { - getSubElements(quaiZone, 'button')[0].click() - await delay(0) - let options = getElements('[data-radix-popper-content-wrapper] [role=option]') - options.filter(x => x.textContent === tags[0])[0].dispatchEvent(new Event('click', { bubbles: true })) - await delay(0) - getSubElements(emplacementZone, 'button')[0].click() - await delay(0) - options = getElements('[data-radix-popper-content-wrapper] [role=option]') - options.filter(x => x.textContent === tags[1])[0].dispatchEvent(new Event('click', { bubbles: true })) - await delay(0) - const groupButtonsToDisable = getElements('.group.cursor-pointer.border-brand-primary') - groupButtonsToDisable.forEach(x => x.click()) - const groupButtons = getElements('.group.cursor-pointer') - groupButtons.filter(x => tags.slice(2).map(x => x.toLocaleUpperCase()).indexOf(x.textContent.toLocaleUpperCase()) >= 0).forEach(x => x.click()) - } + const [inputFileTorrent, inputFileNfo] = inputFiles; + + const eventNames = ['dragenter', 'dragover', 'dragleave', 'drop']; + const actionsByEventName = { + dragenter: (element, eventName) => element.classList.add('x-dragover'), + dragover: (element, eventName) => element.classList.add('x-dragover'), + dragleave: (element, eventName) => element.classList.remove('x-dragover'), + drop: async (element, eventName, event) => { + element.classList.remove('x-dragover') + if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) { + const fileCount = event.dataTransfer.files.length; + console.log(`Fichiers déposés : ${fileCount}`); + let prezFile = null; + let tagsFile = null; + for (let i = 0; i < fileCount; i++) { + console.log(`Fichiers déposés : ${fileCount} (${i + 1}/${fileCount})`); + const filename = event.dataTransfer.files[i].name; + console.log(`Fichier déposé : ${filename}`); + if (filename.endsWith('.nfo')) { + setReactInputFiles(inputFileNfo, fileToFileList(event.dataTransfer.files[i])); + } else if (filename.endsWith('.torrent')) { + setReactInputFiles(inputFileTorrent, fileToFileList(event.dataTransfer.files[i])); + } else if (filename.endsWith('.prez')) { + prezFile = event.dataTransfer.files[i]; + const basename = filename.substring(0, filename.length - '.prez'.length); + setReactInputValue(titreInput, basename); + } else if (filename.endsWith('.tags')) { + tagsFile = event.dataTransfer.files[i]; + } + } + if (prezFile !== null) { + const prezContent = await prezFile.text(); + prezTextarea = await getPrezContent(prezButton, prezZone); + setReactTextareaValue(prezTextarea, prezContent); + await showPrezContent(prezShowContentButton, prezZone); + } + if (tagsFile !== null) { + const tagsContent = await tagsFile.text(); + console.log(`Contenu du fichier .tags : ${tagsContent}`); + const tags = tagsContent.split('\n').map(tag => tag.trim()) + console.log({ tags }); + + if (tags.length > 2) { + getSubElements(quaiZone, 'button')[0].click() + await delay(0) + let options = getElements('[data-radix-popper-content-wrapper] [role=option]') + options.filter(x => x.textContent === tags[0])[0].dispatchEvent(new Event('click', { bubbles: true })) + await delay(0) + getSubElements(emplacementZone, 'button')[0].click() + await delay(0) + options = getElements('[data-radix-popper-content-wrapper] [role=option]') + options.filter(x => x.textContent === tags[1])[0].dispatchEvent(new Event('click', { bubbles: true })) + await delay(0) + const groupButtonsToDisable = getElements('.group.cursor-pointer.border-brand-primary') + groupButtonsToDisable.forEach(x => x.click()) + const groupButtons = getElements('.group.cursor-pointer') + groupButtons.filter(x => tags.slice(2).map(x => x.toLocaleUpperCase()).indexOf(x.textContent.toLocaleUpperCase()) >= 0).forEach(x => x.click()) + } + } + } + }, } - } - }, - } - - eventNames.forEach((eventName) => { - const unreg = registerEventListener(dropElement, eventName, (event) => { - // console.log(`Event ${eventName} déclenché`); - event.preventDefault(); - event.stopPropagation(); - actionsByEventName[eventName](dropElement, eventName, event); - }); - addUninstaller(unreg); - }); + eventNames.forEach((eventName) => { + const unreg = registerEventListener(dropElement, eventName, (event) => { + // console.log(`Event ${eventName} déclenché`); + event.preventDefault(); + event.stopPropagation(); + actionsByEventName[eventName](dropElement, eventName, event); + }); + registrationManager.onRegistration(unreg); + }); + } + } + ) } const main = () => { @@ -190,9 +182,8 @@ const main = () => { } catch (error) { alert(`Une erreur est survenue : ${error.message}`); } - } else { - uninstallAll(); + registrationManager.cleanupAll(); } }); } From 42ca3b77e2c2dbf464ab6908fd2c6b1cfefb15b0 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:21:25 +0100 Subject: [PATCH 21/30] Un userscript pour voir musardine en rose ! --- src/la-cale/la-cale-musardine-en-rose.user.js | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/la-cale/la-cale-musardine-en-rose.user.js diff --git a/src/la-cale/la-cale-musardine-en-rose.user.js b/src/la-cale/la-cale-musardine-en-rose.user.js new file mode 100644 index 0000000..a5f8242 --- /dev/null +++ b/src/la-cale/la-cale-musardine-en-rose.user.js @@ -0,0 +1,26 @@ +// @import{getElements} +// @import{getSubElements} +// @import{registerDomNodeMutatedUnique} +// @import{registerLocationChange} + +const main = async () => { + registerLocationChange((location) => { + if (location.pathname === '/taverne') { + registerDomNodeMutatedUnique(() => getElements('.flex-1.overflow-hidden'), (node) => { + const userMessages = getSubElements(node, '[href="/profile/Musardine"]') + if (userMessages.length > 0) { + console.log({ userMessages }) + getSubElements(userMessages[0], 'span')[0].style.color = 'rgb(223,42,132)' + const nextSibling = userMessages[0]?.nextElementSibling + if (nextSibling) { + nextSibling.style.color = 'rgb(223,42,132)' + nextSibling.style.borderColor = 'rgb(223,42,132,0.45)' + nextSibling.style.backgroundColor = 'rgb(223,42,132,0.12)' + } + } + }) + } + }) +} + +main() \ No newline at end of file From a06a3b44c67fb7b30d711cdde7a5ae1e5b18ed7a Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:51:31 +0100 Subject: [PATCH 22/30] FIX : Fix new team badge impact --- src/la-cale/la-cale-bot.user.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index 6c00adc..1711557 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -42,11 +42,12 @@ class LaCabot { const messageElement = line.children[1].children[1] if (userElements.length >= 3) { props.user = userElements[0].textContent - const fullGradeText = userElements[1].textContent + // userElements[1] is the team + const fullGradeText = userElements[userElements.length-2].textContent const fullGradeSep = fullGradeText.indexOf(' ') props.grade = fullGradeText.slice(fullGradeSep + 1) props.iconGrade = fullGradeText.slice(0, fullGradeSep) - props.serverDate = userElements[2].textContent + props.serverDate = userElements[userElements.length-1].textContent props.clientDate = new Date().toISOString() } for (let messageElement of [...line.children[1].children].slice(1)) { From 9b208d4c4601e0d73d54a397ae41e2425edea1c9 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:03:38 +0100 Subject: [PATCH 23/30] FIX : Support team info in message --- src/la-cale/la-cale-bot.user.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index 1711557..48c0a3e 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -41,13 +41,18 @@ class LaCabot { const userElements = line.children[1].children[0].children const messageElement = line.children[1].children[1] if (userElements.length >= 3) { - props.user = userElements[0].textContent + let index = 0 + props.user = userElements[index++].textContent + const nextElement = userElements[index++] + if (nextElement.tagName === 'a' && nextElement.href.includes('/teams/')) { + props.team = nextElement.textContent + } // userElements[1] is the team - const fullGradeText = userElements[userElements.length-2].textContent + const fullGradeText = userElements[index++].textContent const fullGradeSep = fullGradeText.indexOf(' ') props.grade = fullGradeText.slice(fullGradeSep + 1) props.iconGrade = fullGradeText.slice(0, fullGradeSep) - props.serverDate = userElements[userElements.length-1].textContent + props.serverDate = userElements[index++].textContent props.clientDate = new Date().toISOString() } for (let messageElement of [...line.children[1].children].slice(1)) { From 581b15302a2fa30633ee7a50de40c021893b44e8 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:12:35 +0100 Subject: [PATCH 24/30] FIX : Oups : typo --- src/la-cale/la-cale-bot.user.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index 48c0a3e..ca93c7b 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -43,9 +43,10 @@ class LaCabot { if (userElements.length >= 3) { let index = 0 props.user = userElements[index++].textContent - const nextElement = userElements[index++] + const nextElement = userElements[index] if (nextElement.tagName === 'a' && nextElement.href.includes('/teams/')) { props.team = nextElement.textContent + index++ } // userElements[1] is the team const fullGradeText = userElements[index++].textContent From e83515d56b7b789d287b070b4b899f9726fb2282 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:34:37 +0100 Subject: [PATCH 25/30] FIX : element.tag is uppercase --- src/la-cale/la-cale-bot.user.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js index ca93c7b..10af9e1 100644 --- a/src/la-cale/la-cale-bot.user.js +++ b/src/la-cale/la-cale-bot.user.js @@ -44,11 +44,10 @@ class LaCabot { let index = 0 props.user = userElements[index++].textContent const nextElement = userElements[index] - if (nextElement.tagName === 'a' && nextElement.href.includes('/teams/')) { + if (nextElement.tagName === 'A' && nextElement.href.includes('/teams/')) { props.team = nextElement.textContent index++ } - // userElements[1] is the team const fullGradeText = userElements[index++].textContent const fullGradeSep = fullGradeText.indexOf(' ') props.grade = fullGradeText.slice(fullGradeSep + 1) From 2d5436d1b5b6efce91d43b1aff0fb418d1f54cfa Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:30:24 +0100 Subject: [PATCH 26/30] FIX : Fix robust scan --- src/la-cale/la-cale-musardine-en-rose.user.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/la-cale/la-cale-musardine-en-rose.user.js b/src/la-cale/la-cale-musardine-en-rose.user.js index a5f8242..e41a799 100644 --- a/src/la-cale/la-cale-musardine-en-rose.user.js +++ b/src/la-cale/la-cale-musardine-en-rose.user.js @@ -10,12 +10,15 @@ const main = async () => { const userMessages = getSubElements(node, '[href="/profile/Musardine"]') if (userMessages.length > 0) { console.log({ userMessages }) - getSubElements(userMessages[0], 'span')[0].style.color = 'rgb(223,42,132)' - const nextSibling = userMessages[0]?.nextElementSibling - if (nextSibling) { - nextSibling.style.color = 'rgb(223,42,132)' - nextSibling.style.borderColor = 'rgb(223,42,132,0.45)' - nextSibling.style.backgroundColor = 'rgb(223,42,132,0.12)' + const nameElement = getSubElements(userMessages[0], 'span')[0] + if (nameElement) { + nameElement.style.color = 'rgb(223,42,132)' + const nextSibling = userMessages[0]?.nextElementSibling?.nextElementSibling + if (nextSibling) { + nextSibling.style.color = 'rgb(223,42,132)' + nextSibling.style.borderColor = 'rgb(223,42,132,0.45)' + nextSibling.style.backgroundColor = 'rgb(223,42,132,0.12)' + } } } }) From 8f9020f295304fb93a35ec47c4e5a2a4e8a022f5 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:46:52 +0100 Subject: [PATCH 27/30] CLEANUP --- src/la-cale/la-cale-musardine-en-rose.user.js | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/la-cale/la-cale-musardine-en-rose.user.js b/src/la-cale/la-cale-musardine-en-rose.user.js index e41a799..c6398c6 100644 --- a/src/la-cale/la-cale-musardine-en-rose.user.js +++ b/src/la-cale/la-cale-musardine-en-rose.user.js @@ -6,22 +6,25 @@ const main = async () => { registerLocationChange((location) => { if (location.pathname === '/taverne') { - registerDomNodeMutatedUnique(() => getElements('.flex-1.overflow-hidden'), (node) => { - const userMessages = getSubElements(node, '[href="/profile/Musardine"]') - if (userMessages.length > 0) { - console.log({ userMessages }) - const nameElement = getSubElements(userMessages[0], 'span')[0] - if (nameElement) { - nameElement.style.color = 'rgb(223,42,132)' - const nextSibling = userMessages[0]?.nextElementSibling?.nextElementSibling - if (nextSibling) { - nextSibling.style.color = 'rgb(223,42,132)' - nextSibling.style.borderColor = 'rgb(223,42,132,0.45)' - nextSibling.style.backgroundColor = 'rgb(223,42,132,0.12)' + registerDomNodeMutatedUnique( + () => getElements('.flex-1.overflow-hidden'), + (node) => { + const userMessages = getSubElements(node, '[href="/profile/Musardine"]') + if (userMessages.length > 0) { + console.log({ userMessages }) + const nameElement = getSubElements(userMessages[0], 'span')[0] + if (nameElement) { + nameElement.style.color = 'rgb(223,42,132)' + const nextSibling = userMessages[0]?.nextElementSibling?.nextElementSibling + if (nextSibling) { + nextSibling.style.color = 'rgb(223,42,132)' + nextSibling.style.borderColor = 'rgb(223,42,132,0.45)' + nextSibling.style.backgroundColor = 'rgb(223,42,132,0.12)' + } } } } - }) + ) } }) } From 399904c9bb54075d9157ba1502f9044ad2fafc5a Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:21:26 +0100 Subject: [PATCH 28/30] Suppression sur demande --- src/la-cale/la-cale-bot.user.js | 255 -------------------------------- 1 file changed, 255 deletions(-) delete mode 100644 src/la-cale/la-cale-bot.user.js diff --git a/src/la-cale/la-cale-bot.user.js b/src/la-cale/la-cale-bot.user.js deleted file mode 100644 index 10af9e1..0000000 --- a/src/la-cale/la-cale-bot.user.js +++ /dev/null @@ -1,255 +0,0 @@ -// @import{getElements} -// @import{getSubElements} -// @import{registerDomNodeMutated} -// @import{registerDomNodeMutatedUnique} -// @import{downloadData} -// @import{HookableValue} -// @import{computeSha256} -// @import{registerLocationChange} - -class LaCabot { - constructor() { - this._name = 'LaCabot' - this._version = '0.0.1' - this._onMessageCallbacks = [] - this._onInstalledCallbacks = [] - this._onUninstalledCallbacks = [] - this._titleZone = null - this._contentZone = null - this._writeZone = null - this._toClean = [] - } - - install() { - this._toClean.push(registerDomNodeMutatedUnique(() => getElements('main>div>div'), (element) => { - console.log({ element, ecl: element?.children?.length }) - if (element && element.children.length === 3) { - console.log({ c: element.children }) - const [titleZone, contentZone, writeZone] = element.children - if (!titleZone || !contentZone || !writeZone) { - return false - } - console.log({ titleZone, contentZone, writeZone }) - this._titleZone = titleZone - this._contentZone = contentZone - this._writeZone = writeZone - this._toClean.push(registerDomNodeMutatedUnique(() => getSubElements(this._contentZone, 'div.h-full.overflow-y-auto>div.gap-3').reverse(), (line) => { - // console.log({line}) - if (line.children.length == 3) { - const props = {} - if (line.children && line.children.length >= 3 && line.children[1] && line.children[1].children.length >= 2) { - const userElements = line.children[1].children[0].children - const messageElement = line.children[1].children[1] - if (userElements.length >= 3) { - let index = 0 - props.user = userElements[index++].textContent - const nextElement = userElements[index] - if (nextElement.tagName === 'A' && nextElement.href.includes('/teams/')) { - props.team = nextElement.textContent - index++ - } - const fullGradeText = userElements[index++].textContent - const fullGradeSep = fullGradeText.indexOf(' ') - props.grade = fullGradeText.slice(fullGradeSep + 1) - props.iconGrade = fullGradeText.slice(0, fullGradeSep) - props.serverDate = userElements[index++].textContent - props.clientDate = new Date().toISOString() - } - for (let messageElement of [...line.children[1].children].slice(1)) { - if (messageElement && messageElement.classList.contains('text-text-primary-medium')) { - props.message = messageElement.textContent - props.messageElement = messageElement - } - if (messageElement && messageElement.classList.contains('text-text-primary-muted')) { - props.reference = messageElement.textContent - } - } - } - this._onMessage(props) - } - - return true - })) - this._onInstalled(); - return true - } - return false - })) - return () => { - this._onUninstalled() - for (const clean of this._toClean) { - clean() - } - } - } - - registerOnMessage(callback) { - this._onMessageCallbacks.push(callback) - const result = () => { - this._onMessageCallbacks.splice(this._onMessageCallbacks.indexOf(callback), 1) - } - return result - } - - registerOnInstalled(callback) { - this._onInstalledCallbacks.push(callback) - const result = () => { - this._onInstalledCallbacks.splice(this._onInstalledCallbacks.indexOf(callback), 1) - } - return result - } - - registerOnUninstalled(callback) { - this._onUninstalledCallbacks.push(callback) - const result = () => { - this._onUninstalledCallbacks.splice(this._onUninstalledCallbacks.indexOf(callback), 1) - } - return result - } - - _onMessage(props) { - // console.log({ ...props }) - for (const callback of this._onMessageCallbacks) { - callback(props) - } - } - - _onInstalled() { - for (const callback of this._onInstalledCallbacks) { - callback() - } - } - - _onUninstalled() { - for (const callback of this._onUninstalledCallbacks) { - callback() - } - } - - addButton(icon, onClick) { - const button = document.createElement('button') - button.textContent = icon; - [ - "items-center", - "justify-center", - "gap-2", - "whitespace-nowrap", - "rounded-md", - "text-sm", - "font-medium", - "transition-colors", - "focus-visible:outline-none", - "focus-visible:ring-1", - "focus-visible:ring-ring", - "disabled:pointer-events-none", - "disabled:opacity-50", - "[&_svg]:pointer-events-none", - "[&_svg]:size-4", - "[&_svg]:shrink-0", - "hover:bg-accent", - "h-9", - "w-9", - "hidden", - "sm:flex", - "text-text-primary-medium", - "hover:text-brand-primary", - "cursor-pointer", - "relative" - ].forEach(c => button.classList.add(c)) - const titleBar = this._titleZone.children[0] - titleBar.insertBefore(button, titleBar.children[1]) - button.addEventListener('click', onClick) - return button; - } -} - - -const addLogManagement = (laCabot) => { - let logs = { - idRange: null, - day: null, - messages: [], - isEmpty: new HookableValue('isEmpty', true), - } - window.logs = logs - - const emptyLogs = () => { - if (logs.messages.length > 0) { - const content = logs.messages.map(m => m.messageLog).join('\n') - console.log('Log for idRange', logs.idRange, '\n', content) - const idRangeFilename = logs.messages[0].idRange.replaceAll(':', '-') - const dayFilename = logs.day - computeSha256(content, { encoding: 'utf-8' }).then((hash) => { - const filename = `la-cale-log-${dayFilename}--${idRangeFilename}x-${hash.slice(0, 10)}.txt` - downloadData(filename, content, { encoding: 'utf-8', mimetype: 'text/plain' }) - }) - } - logs.messages = [] - logs.isEmpty.setValue(true) - } - logs.emptyLogs = emptyLogs - - laCabot.registerOnMessage((props) => { - const idRange = props.serverDate.slice(0, 4) - if (logs.idRange !== idRange) { - emptyLogs() - logs.day = props.clientDate.slice(0, 10) - logs.idRange = idRange - } - let messageLog = `[${props.serverDate}] ${props.iconGrade}${props.user}: ${props.message}` - if (props.reference) { - messageLog += ` (> ${props.reference})` - } - logs.messages.push({...props, idRange, messageLog}) - logs.isEmpty.setValue(false) - }) - - laCabot.registerOnInstalled(() => { - const buttonClean = laCabot.addButton('🧹', () => { - emptyLogs(); - }); - - window.buttonClean = buttonClean; - - logs.isEmpty.register((newValue, oldValue) => { - buttonClean.disabled = newValue; - }); - }); - - laCabot.registerOnUninstalled(() => { - emptyLogs(); - }); -} - -const main = async () => { - let lastLocation = null - let unistallLaCabot = null - registerLocationChange((location) => { - if (location.pathname === '/taverne' && location.href !== lastLocation) { - lastLocation = location - if (unistallLaCabot) { - unistallLaCabot() - unistallLaCabot = null - } - const laCabot = new LaCabot() - unistallLaCabot = laCabot.install() - laCabot.registerOnMessage((props) => { - console.log('New message received:', props) - }) - window.laCabot = laCabot - - laCabot.registerOnInstalled(() => { - console.log('LaCabot installed') - }); - - addLogManagement(laCabot) - } else if (location.pathname !== '/taverne' && unistallLaCabot) { - unistallLaCabot() - unistallLaCabot = null - - } - lastLocation = location - }) -} - -main() \ No newline at end of file From 432d5ea62d96c81024a55ac13f076a60056b1f50 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:58:08 +0100 Subject: [PATCH 29/30] Add a script to change image format (vertical, square) on some categories --- ...e-image-format-for-some-categories.user.js | 49 +++++++++++++++++++ src/la-cale/la-cale-musardine-en-rose.user.js | 8 ++- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 src/la-cale/la-cale-change-image-format-for-some-categories.user.js diff --git a/src/la-cale/la-cale-change-image-format-for-some-categories.user.js b/src/la-cale/la-cale-change-image-format-for-some-categories.user.js new file mode 100644 index 0000000..7521d30 --- /dev/null +++ b/src/la-cale/la-cale-change-image-format-for-some-categories.user.js @@ -0,0 +1,49 @@ +// @import{getElements} +// @import{getSubElements} +// @import{registerDomNodeMutatedUnique} +// @import{registerLocationChange} +// @import{RegistrationManager} +// @import{monkeyGetSetValue} + +const categoriesToChangeVertical = monkeyGetSetValue('categoriesToChangeVertical', ['BD','Comics','Divers','Livres','Mangas','Presse']) +const categoriesToChangeSquare = monkeyGetSetValue('categoriesToChangeSquare', ['BD','Comics','Divers','Livres','Mangas','Presse']) + +const categoriesToChange = [ + { + list: categoriesToChangeVertical, + newClass: 'aspect-[2/3]', + oldClass: 'aspect-video', + }, + { + list: categoriesToChangeSquare, + newClass: 'aspect-square', + oldClass: 'aspect-video', + }, +] + +const main = async () => { + const registrationManager = new RegistrationManager() + registerLocationChange((location) => { + registrationManager.cleanupAll() + + const pathParts = location.pathname.split('/') + if (pathParts.length === 3 && pathParts[1] === 'torrents') { + registrationManager.onRegistration(registerDomNodeMutatedUnique( + () => getElements('.flex.flex-wrap.items-center.gap-2.mb-3>div.bg-brand-primary'), + (node) => { + for (const { list, newClass, oldClass } of categoriesToChange) { + if (list.indexOf(node.textContent) > -1) { + const imageContainer = getElements(`.${oldClass}`)[0] + if (imageContainer) { + imageContainer.classList.remove(oldClass) + imageContainer.classList.add(newClass) + } + } + } + } + )) + } + }) +} + +main() diff --git a/src/la-cale/la-cale-musardine-en-rose.user.js b/src/la-cale/la-cale-musardine-en-rose.user.js index c6398c6..0bd8b3a 100644 --- a/src/la-cale/la-cale-musardine-en-rose.user.js +++ b/src/la-cale/la-cale-musardine-en-rose.user.js @@ -2,11 +2,15 @@ // @import{getSubElements} // @import{registerDomNodeMutatedUnique} // @import{registerLocationChange} +// @import{RegistrationManager} const main = async () => { + const registrationManager = new RegistrationManager() registerLocationChange((location) => { + registrationManager.cleanupAll() + if (location.pathname === '/taverne') { - registerDomNodeMutatedUnique( + registrationManager.onRegistration(registerDomNodeMutatedUnique( () => getElements('.flex-1.overflow-hidden'), (node) => { const userMessages = getSubElements(node, '[href="/profile/Musardine"]') @@ -24,7 +28,7 @@ const main = async () => { } } } - ) + )) } }) } From 1ef4e58797ab957cd332f53317198c32dd3f6b50 Mon Sep 17 00:00:00 2001 From: kaberly <257349530+kaberly@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:59:43 +0100 Subject: [PATCH 30/30] FIX : Fix categories to change --- .../la-cale-change-image-format-for-some-categories.user.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/la-cale/la-cale-change-image-format-for-some-categories.user.js b/src/la-cale/la-cale-change-image-format-for-some-categories.user.js index 7521d30..8b84684 100644 --- a/src/la-cale/la-cale-change-image-format-for-some-categories.user.js +++ b/src/la-cale/la-cale-change-image-format-for-some-categories.user.js @@ -5,8 +5,8 @@ // @import{RegistrationManager} // @import{monkeyGetSetValue} -const categoriesToChangeVertical = monkeyGetSetValue('categoriesToChangeVertical', ['BD','Comics','Divers','Livres','Mangas','Presse']) -const categoriesToChangeSquare = monkeyGetSetValue('categoriesToChangeSquare', ['BD','Comics','Divers','Livres','Mangas','Presse']) +const categoriesToChangeVertical = monkeyGetSetValue('categoriesToChangeVertical', ['BD','Comics','Divers','Livres','Mangas','Presse','Audiobooks']) +const categoriesToChangeSquare = monkeyGetSetValue('categoriesToChangeSquare', ['Musique','Podcast']) const categoriesToChange = [ {