From c0a442802617ccd37e4ceeb17410bbdc12c6215e Mon Sep 17 00:00:00 2001 From: Mike Castle Date: Wed, 22 Jul 2026 09:03:14 -0700 Subject: [PATCH 01/10] JSDoc: Fix all known issues for `lib/userscript`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #369. ␄ --- lib/userscript.js | 121 +++++++++++++++++++++++++++++++--------------- lib/userscript.md | 2 +- 2 files changed, 83 insertions(+), 40 deletions(-) diff --git a/lib/userscript.js b/lib/userscript.js index c7da7c02..d4b37085 100644 --- a/lib/userscript.js +++ b/lib/userscript.js @@ -2,7 +2,7 @@ // ==UserLibrary== // @name NH_userscript // @description Wrappers for dealing with variations in userscript managers. -// @version 17 +// @version 18 // @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html // @homepageURL https://github.com/nexushoratio/userscripts // @supportURL https://github.com/nexushoratio/userscripts/issues @@ -10,30 +10,46 @@ // ==/UserLibrary== // ==/UserScript== +/** + * @file Wrappers for dealing with variations in userscript managers. + * @module userscript + * @version 18 + * @license [GPL-3.0-or-later]{@link https://www.gnu.org/licenses/gpl-3.0-standalone.html} + */ + window.NexusHoratio ??= {}; window.NexusHoratio.userscript = (function userscript() { 'use strict'; - /** @type {number} - Bumped per release. */ - const version = 17; + /** @const {number} module:userscript.version - Bumped per release. */ + const version = 18; const NH = window.NexusHoratio.base.ensure( - [{name: 'base', minVersion: 61}] + [{name: 'base', minVersion: 74}] ); /** - * @typedef LicenseData - * @deprecated @property {string} name - License name. + * Replace {@link module:userscript.LicenseData~name} with {@link + * module:userscript.LicenseData['id']} member of {@link + * module:userscript.LicenseData}. + * + * @typedef {object} module:userscript.LicenseData + * @property {string} name - License name. + * @deprecated Replace {@link module:userscript.LicenseData#name} with + * {@link module:userscript.LicenseData#id} * @property {string} id - SPDX id (fka `name`). * @property {string} url - URL pointing to the license. */ /** - * Per the *old* GM docs: - * https://sourceforge.net/p/greasemonkey/wiki/Metadata_Block/#license - * @returns {LicenseData} - Extracted from the userscript header. - * @throws {Error} - If cannot be extracted. + * Per some *old* [GM docs]{@link + * https://sourceforge.net/p/greasemonkey/wiki/Metadata_Block/#license} + * + * @function module:userscript.licenseData + * @returns {module:userscript.LicenseData} Extracted from the userscript + * header. + * @throws {Error} If cannot be extracted. */ function licenseData() { let license = GM.info.script.license; @@ -76,7 +92,10 @@ window.NexusHoratio.userscript = (function userscript() { }; } - /** @returns {string[]} - Raw text about the current environment. */ + /** + * @function module:userscript.environmentData + * @returns {string[]} Raw text about the current environment. + */ function environmentData() { const gm = GM.info; const msgs = [`${gm.script.name}: ${gm.script.version}`]; @@ -117,16 +136,19 @@ window.NexusHoratio.userscript = (function userscript() { } /** - * Fetches value from userscript storage if granted. + * Fetches value from userscript storage, if granted permission. * * Purposefully no errors if permissions are not granted. * - * Enable in the ==UserScript== header with: - * @grant GM.getValue + * ``` + * // Enable in the ==UserScript== header with: + * // @grant GM.getValue + * ``` * + * @function module:userscript.getValue * @param {string} key - The name of the value to load. - * @param {*} defaultValue - Value if not stored OR not enabled. - * @returns {Promise<*>} - The value fetched or defaultValue. + * @param {object} defaultValue - Value if not stored OR not enabled. + * @returns {Promise} The value fetched or defaultValue. */ function getValue(key, defaultValue) { if (GM.getValue) { @@ -136,17 +158,20 @@ window.NexusHoratio.userscript = (function userscript() { } /** - * Sets a value in userscript storage if granted. + * Sets a value in userscript storage, if granted permission. * * Purposefully no errors if permissions are not granted. * - * Enable in the ==UserScript== header with: - * @grant GM.setValue + * ``` + * // Enable in the ==UserScript== header with: + * // @grant GM.setValue + * ``` * + * @function module:userscript.setValue * @param {string} key - The name to use in the storage. - * @param {*} value - The value to set. - * @returns {Promise<*>} - Always resolves to null; mostly used to ensure - * the write is complete. + * @param {object} value - The value to set. + * @returns {Promise} Always resolves to null; mostly used to + * ensure the write is complete. */ function setValue(key, value) { if (GM.setValue) { @@ -156,24 +181,27 @@ window.NexusHoratio.userscript = (function userscript() { } /** - * Automatically load/save Logger configs based on state changes. + * Automatically load/save {@link module:base.Logger Logger} configs based + * on state changes. * * This particular implementation is intended to be efficient but imperfect. * Data, at least as far as high water marks, may be lost. * - * This uses {@link getValue} and {@link setValue). See them for enabling - * in a userscript. - * * It monitors changes to Logger configs, and saves them to userscript * storage as appropriate. * - * If the userscript enables the value change listener API, that will be - * used to detect when values need to be reread. Otherwise, it will use the - * browser based visibilitychange event to reload. + * If the userscript enables the value change listener API (see below), that + * will be used to detect when values need to be reread. Otherwise, it will + * use the browser based `visibilitychange` event to reload. + * + * ``` + * // Enable in the ==UserScript== header with: + * // @grant GM.addValueChangeListener + * // @grant GM.removeValueChangeListener + * ``` * - * Enable in the ==UserScript== header with: - * @grant GM.addValueChangeListener - * @grant GM.removeValueChangeListener + * @see {module:userscript.getValue} + * @see {module:userscript.setValue} */ class LoggerConfigSaver { @@ -224,6 +252,7 @@ window.NexusHoratio.userscript = (function userscript() { #usedCount = 0; #vclId + /** @method */ #load = () => { const me = this.#load.name; @@ -235,7 +264,10 @@ window.NexusHoratio.userscript = (function userscript() { })(); } - /** @param {string} reason - Attached to mutex as a debugging aid. */ + /** + * @method + * @param {string} reason - Attached to mutex as a debugging aid. + */ #save = (reason) => { this.#usedCount = 0; this.#mutex = (async () => { @@ -246,9 +278,10 @@ window.NexusHoratio.userscript = (function userscript() { } /** + * @method * @param {string} key - The name of the observed variable. - * @param {*} oldValue - The old value of the observed variable. - * @param {*} newValue - The new value of the observed variable. + * @param {object} oldValue - The old value of the observed variable. + * @param {object} newValue - The new value of the observed variable. * @param {boolean} remote - Same script or different tab. */ #valueChangeListener = (key, oldValue, newValue, remote) => { // eslint-disable-line max-params @@ -258,10 +291,11 @@ window.NexusHoratio.userscript = (function userscript() { } /** - * @implements {NH.base.Logger~ConfigMutationHandler} + * @method + * @implements {module:base.Logger~ConfigMutationHandler} * @param {string} evt - Type of mutation. - * @param {NH.base.Logger~ConfigMutationRecord} record - Details about the - * mutation. + * @param {module:base.Logger~ConfigMutationRecord} record - Details about + * the mutation. */ #onLoggerConfig = (evt, record) => { if (!LoggerConfigSaver.#ignoreList.includes(record.logger)) { @@ -294,8 +328,17 @@ window.NexusHoratio.userscript = (function userscript() { * * Call once near the top of the userscript to enable. * - * See {@link LoggerConfigSaver} for details. + * Requires several userscript grants for full capabilities. + * ``` + * // Enable in the ==UserScript== header with: + * // @grant GM.getValue + * // @grant GM.setValue + * // @grant GM.addValueChangeListener + * // @grant GM.removeValueChangeListener + * ``` * + * @see {module:userscript~LoggerConfigSaver} + * @function module:userscript.setAutoManageLoggerConfigs * @param {boolean} state - Enables/disables the feature. * @param {string} [key='Logger'] - The key to use in userscript storage. */ diff --git a/lib/userscript.md b/lib/userscript.md index 9038f788..89cfe31d 100644 --- a/lib/userscript.md +++ b/lib/userscript.md @@ -2,7 +2,7 @@ Wrappers for dealing with variations in userscript managers. -## Exported properties (as of version 14) +## Exported properties (as of version 18) * version - Bumped per release. * licenseData - License data extracted from the userscript header. * environmentData - Raw text about the current environment. From cf29e338844ee281af6bce0601d567ba694c8696 Mon Sep 17 00:00:00 2001 From: Mike Castle Date: Wed, 22 Jul 2026 09:04:45 -0700 Subject: [PATCH 02/10] JSDoc: Enable processing for `lib/userscript`. Issue #369. --- .jsdoc.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.jsdoc.json b/.jsdoc.json index 62dcc3ed..ac0ed53b 100644 --- a/.jsdoc.json +++ b/.jsdoc.json @@ -3,7 +3,12 @@ "private": false }, "source": { - "include": ["demos.user.js", "lib/xunit.js", "lib/base.js"] + "include": [ + "demos.user.js", + "lib/xunit.js", + "lib/base.js", + "lib/userscript.js" + ] }, "plugins": ["plugins/markdown"], "markdown": { From 03e0aaf6aba5840b4475cf246595288d928b5ffa Mon Sep 17 00:00:00 2001 From: Mike Castle Date: Wed, 22 Jul 2026 10:09:21 -0700 Subject: [PATCH 03/10] Update `UidMode`s for the *Activity* section. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #302. ␄ --- linkedin-tool.user.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/linkedin-tool.user.js b/linkedin-tool.user.js index 57925f8c..b713253a 100644 --- a/linkedin-tool.user.js +++ b/linkedin-tool.user.js @@ -9429,9 +9429,9 @@ modes: [ this.ctor.UidMode.ANCHOR_FEED, this.ctor.UidMode.COMMENT_URN, - this.ctor.UidMode.HREF, - this.ctor.UidMode.IMG, - this.ctor.UidMode.ANCHOR, + this.ctor.UidMode.MULTI_IMG, + this.ctor.UidMode.ANCHOR_PULSE, + this.ctor.UidMode.ANCHOR_NEWSLETTERS, this.ctor.UidMode.FOOTER, ], }); @@ -9733,7 +9733,10 @@ href = element.querySelector('a[href*="/in/"]')?.href; break; case this.ctor.UidMode.ANCHOR_PULSE: - href = element.querySelector('a[href*="/pulse/"]')?.href; + scratch = element.matches('a[href *= "/pulse/"]') + ? element + : element.querySelector('a[href *= "/pulse/"]'); + href = scratch?.href; break; case this.ctor.UidMode.ARIA_LABEL: content = element.ariaLabel || From 4464053c5a08a2fe7e245ebdcdf5c197781955c7 Mon Sep 17 00:00:00 2001 From: Mike Castle Date: Wed, 22 Jul 2026 10:37:57 -0700 Subject: [PATCH 04/10] Make `UidMode` an instance variable. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #352. ␄ --- linkedin-tool.user.js | 250 +++++++++++++++++++++--------------------- 1 file changed, 125 insertions(+), 125 deletions(-) diff --git a/linkedin-tool.user.js b/linkedin-tool.user.js index b713253a..15a2d9de 100644 --- a/linkedin-tool.user.js +++ b/linkedin-tool.user.js @@ -8809,7 +8809,19 @@ this.#initScrollers(); } - static UidMode = Object.freeze({ + /** + * Create a CSS child combinator selector of DIVs. + * + * @param {number} n - The number of DIVs in the selector. + * @returns {string} - N divs like "div > div > ... > div". + */ + static div(n) { + const a = Array(n) + .fill('div'); + return a.join(' > '); + } + + UidMode = Object.freeze({ ANCHOR: Symbol.for('anchor'), ANCHOR_FEED: Symbol.for('anchorFeed'), ANCHOR_LEARNING: Symbol.for('anchorLearning'), @@ -8833,18 +8845,6 @@ TEST_ID: Symbol.for('testId'), }) - /** - * Create a CSS child combinator selector of DIVs. - * - * @param {number} n - The number of DIVs in the selector. - * @returns {string} - N divs like "div > div > ... > div". - */ - static div(n) { - const a = Array(n) - .fill('div'); - return a.join(' > '); - } - /** @type {Scroller} */ get entries() { if (!this.#entryScroller && this.sections.item) { @@ -9325,27 +9325,27 @@ this.ctor.#entriesSelectorDefault, this.ctor.#entriesSelectorFooter, ], - modes: [this.ctor.UidMode.FOOTER], + modes: [this.UidMode.FOOTER], } ); this.#entriesScrollerConfigs.set('Topcard', { uidCallback: this.#entriesUidFromModes, selectors: [this.ctor.#entriesSelectorTopcard], modes: [ - this.ctor.UidMode.ARIA_LABEL, - this.ctor.UidMode.ANCHOR_OVERLAY, - this.ctor.UidMode.ANCHOR, - this.ctor.UidMode.MULTI_IMG, - this.ctor.UidMode.SAFETY, - this.ctor.UidMode.HREF, + this.UidMode.ARIA_LABEL, + this.UidMode.ANCHOR_OVERLAY, + this.UidMode.ANCHOR, + this.UidMode.MULTI_IMG, + this.UidMode.SAFETY, + this.UidMode.HREF, // Hopefully only triggered by "Open to Work" - this.ctor.UidMode.ANCHOR_PROFILE, + this.UidMode.ANCHOR_PROFILE, ], }); this.#entriesScrollerConfigs.set('SuggestedForYou', { uidCallback: this.#entriesUidFromModes, selectors: [this.ctor.#entriesSelectorSuggestedForYou], - modes: [this.ctor.UidMode.ANCHOR_OVERLAY], + modes: [this.UidMode.ANCHOR_OVERLAY], }); this.#entriesScrollerConfigs.set('Analytics', { uidCallback: this.#entriesUidFromModes, @@ -9354,8 +9354,8 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.HREF, - this.ctor.UidMode.FOOTER, + this.UidMode.HREF, + this.UidMode.FOOTER, ], }); // TODO(#302): This looks to have renamed to SalesInsightsOrHighlights. @@ -9366,10 +9366,10 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.COMPANY, - this.ctor.UidMode.ANCHOR_PROFILE, - this.ctor.UidMode.ARIA_LABEL, - this.ctor.UidMode.FOOTER, + this.UidMode.COMPANY, + this.UidMode.ANCHOR_PROFILE, + this.UidMode.ARIA_LABEL, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('SalesInsightsOrHighlights', { @@ -9379,18 +9379,18 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.COMPANY, - this.ctor.UidMode.ANCHOR_PROFILE, - this.ctor.UidMode.ARIA_LABEL, - this.ctor.UidMode.FOOTER, + this.UidMode.COMPANY, + this.UidMode.ANCHOR_PROFILE, + this.UidMode.ARIA_LABEL, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('About', { uidCallback: this.#entriesUidFromModes, selectors: [this.ctor.#entriesSelectorAbout], modes: [ - this.ctor.UidMode.ANCHOR_OVERLAY, - this.ctor.UidMode.DEFAULT, + this.UidMode.ANCHOR_OVERLAY, + this.UidMode.DEFAULT, ], }); this.#entriesScrollerConfigs.set('Services', { @@ -9400,24 +9400,24 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.MULTI_IMG, - this.ctor.UidMode.HREF, - this.ctor.UidMode.FOOTER, + this.UidMode.MULTI_IMG, + this.UidMode.HREF, + this.UidMode.FOOTER, // ID goes after FOOTER because it may match the right arrow. - this.ctor.UidMode.ID, + this.UidMode.ID, ], }); this.#entriesScrollerConfigs.set('Featured', { uidCallback: this.#entriesUidFromModes, selectors: [this.ctor.#entriesSelectorFeatured], modes: [ - this.ctor.UidMode.SAFETY, - this.ctor.UidMode.ANCHOR_FEED, - this.ctor.UidMode.ANCHOR_LEARNING, - this.ctor.UidMode.ANCHOR_NEWSLETTERS, - this.ctor.UidMode.ANCHOR_OVERLAY, - this.ctor.UidMode.ANCHOR_PULSE, - this.ctor.UidMode.ANCHOR_PROFILE, + this.UidMode.SAFETY, + this.UidMode.ANCHOR_FEED, + this.UidMode.ANCHOR_LEARNING, + this.UidMode.ANCHOR_NEWSLETTERS, + this.UidMode.ANCHOR_OVERLAY, + this.UidMode.ANCHOR_PULSE, + this.UidMode.ANCHOR_PROFILE, ], }); this.#entriesScrollerConfigs.set('Activity', { @@ -9427,12 +9427,12 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.ANCHOR_FEED, - this.ctor.UidMode.COMMENT_URN, - this.ctor.UidMode.MULTI_IMG, - this.ctor.UidMode.ANCHOR_PULSE, - this.ctor.UidMode.ANCHOR_NEWSLETTERS, - this.ctor.UidMode.FOOTER, + this.UidMode.ANCHOR_FEED, + this.UidMode.COMMENT_URN, + this.UidMode.MULTI_IMG, + this.UidMode.ANCHOR_PULSE, + this.UidMode.ANCHOR_NEWSLETTERS, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('ExperienceTopLevelSection', { @@ -9442,10 +9442,10 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.ANCHOR_OVERLAY, - this.ctor.UidMode.COMPANY, - this.ctor.UidMode.HREF, - this.ctor.UidMode.FOOTER, + this.UidMode.ANCHOR_OVERLAY, + this.UidMode.COMPANY, + this.UidMode.HREF, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('EducationTopLevelSection', { @@ -9455,17 +9455,17 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.SCHOOL, - this.ctor.UidMode.HREF, - this.ctor.UidMode.ANCHOR_OVERLAY, - this.ctor.UidMode.FOOTER, + this.UidMode.SCHOOL, + this.UidMode.HREF, + this.UidMode.ANCHOR_OVERLAY, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('ConnectedAccountsTopLevel', { // Dismissible section with little support here. uidCallback: this.#entriesUidFromModes, selectors: [this.ctor.#entriesSelectorConnectedAccounts], - modes: [this.ctor.UidMode.HREF], + modes: [this.UidMode.HREF], }); this.#entriesScrollerConfigs.set('CertificationTopLevel', { uidCallback: this.#entriesUidFromModes, @@ -9475,13 +9475,13 @@ ], modes: [ // /safety/go links often go to other certification sites. - this.ctor.UidMode.SAFETY, - this.ctor.UidMode.ANCHOR, + this.UidMode.SAFETY, + this.UidMode.ANCHOR, // For "skill association" pop-up for unlisted programs. - this.ctor.UidMode.ANCHOR_PROFILE, - this.ctor.UidMode.COMPANY, - this.ctor.UidMode.SCHOOL, - this.ctor.UidMode.FOOTER, + this.UidMode.ANCHOR_PROFILE, + this.UidMode.COMPANY, + this.UidMode.SCHOOL, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('Projects', { @@ -9491,12 +9491,12 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.SAFETY, + this.UidMode.SAFETY, // For "skill association" pop-up. - this.ctor.UidMode.ANCHOR_PROFILE, - this.ctor.UidMode.HREF, - this.ctor.UidMode.TEST_ID, - this.ctor.UidMode.FOOTER, + this.UidMode.ANCHOR_PROFILE, + this.UidMode.HREF, + this.UidMode.TEST_ID, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('VolunteerExperienceTopLevel', { @@ -9506,10 +9506,10 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.COMPANY, - this.ctor.UidMode.HREF, - this.ctor.UidMode.TEST_ID, - this.ctor.UidMode.FOOTER, + this.UidMode.COMPANY, + this.UidMode.HREF, + this.UidMode.TEST_ID, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('Skills', { @@ -9520,9 +9520,9 @@ ], modes: [ // These actually look stable. - this.ctor.UidMode.CKEY, - this.ctor.UidMode.HREF, - this.ctor.UidMode.FOOTER, + this.UidMode.CKEY, + this.UidMode.HREF, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('RecommendationsTopLevel', { @@ -9532,9 +9532,9 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.ANCHOR_PROFILE, - this.ctor.UidMode.HREF, - this.ctor.UidMode.FOOTER, + this.UidMode.ANCHOR_PROFILE, + this.UidMode.HREF, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('PublicationTopLevelSection', { @@ -9544,9 +9544,9 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.SAFETY, - this.ctor.UidMode.HREF, - this.ctor.UidMode.FOOTER, + this.UidMode.SAFETY, + this.UidMode.HREF, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('Patents', { @@ -9556,9 +9556,9 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.SAFETY, - this.ctor.UidMode.HREF, - this.ctor.UidMode.FOOTER, + this.UidMode.SAFETY, + this.UidMode.HREF, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('CourseTopLevelSection', { @@ -9567,7 +9567,7 @@ this.ctor.#entriesSelectorCourses, this.ctor.#entriesSelectorFooter, ], - modes: [this.ctor.UidMode.FOOTER], + modes: [this.UidMode.FOOTER], }); this.#entriesScrollerConfigs.set('HonorsTopLevel', { uidCallback: this.#entriesUidFromModes, @@ -9576,9 +9576,9 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.ANCHOR_OVERLAY, - this.ctor.UidMode.HREF, - this.ctor.UidMode.FOOTER, + this.UidMode.ANCHOR_OVERLAY, + this.UidMode.HREF, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('TestScoresTopLevel', { @@ -9587,7 +9587,7 @@ this.ctor.#entriesSelectorTestScores, this.ctor.#entriesSelectorFooter, ], - modes: [this.ctor.UidMode.FOOTER], + modes: [this.UidMode.FOOTER], }); this.#entriesScrollerConfigs.set('LanguageTopLevel', { uidCallback: this.#entriesUidFromModes, @@ -9595,7 +9595,7 @@ this.ctor.#entriesSelectorLanguages, this.ctor.#entriesSelectorFooter, ], - modes: [this.ctor.UidMode.HREF], + modes: [this.UidMode.HREF], }); this.#entriesScrollerConfigs.set('Organizations', { uidCallback: this.#entriesUidFromModes, @@ -9603,7 +9603,7 @@ this.ctor.#entriesSelectorOrganizations, this.ctor.#entriesSelectorFooter, ], - modes: [this.ctor.UidMode.FOOTER], + modes: [this.UidMode.FOOTER], }); this.#entriesScrollerConfigs.set('Interests', { uidCallback: this.#entriesUidFromModes, @@ -9612,12 +9612,12 @@ this.ctor.#entriesSelectorFooter, ], modes: [ - this.ctor.UidMode.ANCHOR_PROFILE, - this.ctor.UidMode.COMPANY, - this.ctor.UidMode.SCHOOL, - this.ctor.UidMode.ANCHOR, - this.ctor.UidMode.HREF, - this.ctor.UidMode.FOOTER, + this.UidMode.ANCHOR_PROFILE, + this.UidMode.COMPANY, + this.UidMode.SCHOOL, + this.UidMode.ANCHOR, + this.UidMode.HREF, + this.UidMode.FOOTER, ], }); this.#entriesScrollerConfigs.set('Causes', { @@ -9628,7 +9628,7 @@ ], modes: [ // Nothing better to use. - this.ctor.UidMode.DEFAULT, + this.UidMode.DEFAULT, ], }); } @@ -9689,7 +9689,7 @@ content = null; href = null; switch (mode) { - case this.ctor.UidMode.ANCHOR: + case this.UidMode.ANCHOR: href = element.querySelector( 'a' + ':not([href*="/company/"])' + @@ -9703,16 +9703,16 @@ ':not([href*="/school/"])' )?.href; break; - case this.ctor.UidMode.ANCHOR_FEED: + case this.UidMode.ANCHOR_FEED: href = element.querySelector('a[href*="/feed/"]')?.href; break; - case this.ctor.UidMode.ANCHOR_LEARNING: + case this.UidMode.ANCHOR_LEARNING: href = element.querySelector('a[href*="/learning/"]')?.href; break; - case this.ctor.UidMode.ANCHOR_NEWSLETTERS: + case this.UidMode.ANCHOR_NEWSLETTERS: href = element.querySelector('a[href*="/newsletters/"]')?.href; break; - case this.ctor.UidMode.ANCHOR_OVERLAY: + case this.UidMode.ANCHOR_OVERLAY: scratch = element.querySelector('a')?.href; if (scratch) { // eslint-disable-next-line prefer-regex-literals @@ -9729,32 +9729,32 @@ } } break; - case this.ctor.UidMode.ANCHOR_PROFILE: + case this.UidMode.ANCHOR_PROFILE: href = element.querySelector('a[href*="/in/"]')?.href; break; - case this.ctor.UidMode.ANCHOR_PULSE: + case this.UidMode.ANCHOR_PULSE: scratch = element.matches('a[href *= "/pulse/"]') ? element : element.querySelector('a[href *= "/pulse/"]'); href = scratch?.href; break; - case this.ctor.UidMode.ARIA_LABEL: + case this.UidMode.ARIA_LABEL: content = element.ariaLabel || element.querySelector('[aria-label]') ?.getAttribute('aria-label'); break; - case this.ctor.UidMode.CKEY: + case this.UidMode.CKEY: content = LinkedIn.ckeyIdentifier(element) ?.replace('com.linkedin.sdui.profile.', ''); break; - case this.ctor.UidMode.COMMENT_URN: + case this.UidMode.COMMENT_URN: // The ?? is so there is always a valid URL scratch = new URL(element.href ?? document.location) .searchParams; content = scratch.get('dashReplyUrn') ?? scratch.get('dashCommentUrn'); break; - case this.ctor.UidMode.COMPANY: + case this.UidMode.COMPANY: scratch = element.querySelector('a[href*="/company/"]') ?.href; if (scratch) { @@ -9763,41 +9763,41 @@ scroller.defaultUid(element); } break; - case this.ctor.UidMode.DEFAULT: + case this.UidMode.DEFAULT: content = scroller.defaultUid(element); break; - case this.ctor.UidMode.FALLBACK: + case this.UidMode.FALLBACK: // No-op break; - case this.ctor.UidMode.FOOTER: + case this.UidMode.FOOTER: scratch = element.matches(this.ctor.#entriesSelectorFooter); if (scratch) { href = element.href; } break; - case this.ctor.UidMode.HREF: + case this.UidMode.HREF: scratch = element.matches(this.ctor.#entriesSelectorFooter); if (!scratch) { href = element.href; } break; - case this.ctor.UidMode.ID: + case this.UidMode.ID: scratch = element.matches(this.ctor.#entriesUidSelectorId) ? element : element.querySelector(this.ctor.#entriesUidSelectorId); content = scratch?.id; break; - case this.ctor.UidMode.IMG: + case this.UidMode.IMG: href = element.querySelector(':scope:is(a) img')?.src; break; - case this.ctor.UidMode.MULTI_IMG: + case this.UidMode.MULTI_IMG: scratch = []; for (const img of element.querySelectorAll('img')) { scratch.push(new URL(img.src).pathname); } content = scratch.join('|'); break; - case this.ctor.UidMode.SAFETY: + case this.UidMode.SAFETY: scratch = element.querySelector('a[href*="/safety/"]') ?.href; if (scratch) { @@ -9806,7 +9806,7 @@ new URL(scratch).searchParams); } break; - case this.ctor.UidMode.SCHOOL: + case this.UidMode.SCHOOL: scratch = element.querySelector('a[href*="/school/"]') ?.href; if (scratch) { @@ -9815,7 +9815,7 @@ scroller.defaultUid(element); } break; - case this.ctor.UidMode.TEST_ID: + case this.UidMode.TEST_ID: scratch = element.matches(this.ctor.#entriesUidSelectorTestId) ? element : element.querySelector(this.ctor.#entriesUidSelectorTestId); @@ -9837,7 +9837,7 @@ : ''; results.set(mode, pathname + extra); if (document.location.pathname === pathname) { - if (mode === this.ctor.UidMode.HREF) { + if (mode === this.UidMode.HREF) { this.logger.log('ignoring self href'); results.delete(mode); } else { @@ -9867,7 +9867,7 @@ const suggestions = []; const results = this.#entriesModeToUid( - scroller, element, Object.values(this.ctor.UidMode) + scroller, element, Object.values(this.UidMode) ); for (const [key, value] of results.entries()) { suggestions.push(key); @@ -9911,7 +9911,7 @@ if (results.size === 0) { this.#entriesSuggestUids(scroller, element); - results.set(this.ctor.UidMode.FALLBACK, scroller.defaultUid(element)); + results.set(this.UidMode.FALLBACK, scroller.defaultUid(element)); } const [mode, uid] = results From eefa617d09b65c7ca8dd928a888ffde1078d9254 Mon Sep 17 00:00:00 2001 From: Mike Castle Date: Wed, 22 Jul 2026 11:56:31 -0700 Subject: [PATCH 05/10] Bring back a recently deleted logging statement. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It turns out 0e00abf was wrong. Issue #372. ␄ --- linkedin-tool.user.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linkedin-tool.user.js b/linkedin-tool.user.js index 15a2d9de..ce35c886 100644 --- a/linkedin-tool.user.js +++ b/linkedin-tool.user.js @@ -1558,7 +1558,8 @@ this.logger.entered(me); await this.#startContainers(); - await this.#currentItemWatcher(); + // The logging statement is useful for debugging. Keep it. + this.logger.log('watcher:', await this.#currentItemWatcher()); this.#mutationDispatcher.on('attributes', this.#attributesHandler); this.#mutationDispatcher.on('childList', this.#monitorConnectedness); From bf12424bc7c356b83fe9f057e2231fb02dfa3d23 Mon Sep 17 00:00:00 2001 From: Mike Castle Date: Wed, 22 Jul 2026 11:59:58 -0700 Subject: [PATCH 06/10] Replace a one line log with intro/outro. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That old one was confusing. Issue #372. ␄ --- linkedin-tool.user.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/linkedin-tool.user.js b/linkedin-tool.user.js index ce35c886..bf159697 100644 --- a/linkedin-tool.user.js +++ b/linkedin-tool.user.js @@ -2213,7 +2213,9 @@ /** Dispatcher monitor. */ const moCallback = () => { - this.logger.log('moCallback'); + const monMe = moCallback.name; + this.logger.entered(monMe); + if (this.gotoUid(uid)) { this.logger.log('item is present', this.item); if (Scroller.#isItemViewable(this.item)) { @@ -2227,6 +2229,8 @@ } else { this.logger.log('not ready yet'); } + + this.logger.leaving(monMe); }; /** Standard setTimeout callback. */ From 0200a7ed52098e9ae52b42838e6403a84727b12f Mon Sep 17 00:00:00 2001 From: Mike Castle Date: Wed, 22 Jul 2026 12:16:17 -0700 Subject: [PATCH 07/10] Update the current page navigation selector. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #209. ␄ --- linkedin-tool.user.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linkedin-tool.user.js b/linkedin-tool.user.js index bf159697..d24ff89c 100644 --- a/linkedin-tool.user.js +++ b/linkedin-tool.user.js @@ -10676,7 +10676,8 @@ try { const timeout = 2000; const item = await NH.web.waitForSelector( - 'div.artdeco-pagination > ul > li.selected', + '[data-testid="pagination-controls-list"] > li' + + ' > [aria-current="true"]', timeout ); this.paginator.goto(item); From 605ce1ccf808451d991e74e0c700eccc5e3194de Mon Sep 17 00:00:00 2001 From: Mike Castle Date: Wed, 22 Jul 2026 15:22:10 -0700 Subject: [PATCH 08/10] Additional monitoring for containers and enable the item cache. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no way to detect when an observed element itself is removed. So the existing `#containersMutationsObserver()` cannot help here. This helps quite a bit. But there is still a bug where, sometimes, the navigation scroller stops working, and unable to create reproducible case. Issue #372. ␄ --- .ispell_words | 7 +++---- linkedin-tool.user.js | 26 +++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.ispell_words b/.ispell_words index bc8323c0..f038b26f 100644 --- a/.ispell_words +++ b/.ispell_words @@ -1,4 +1,3 @@ -aabda abcd ABCd aboutTab @@ -322,7 +321,6 @@ EnsureTestCase ent entriesCurrentModes entriesCurrentUid -entriesMentionUidPossibilities entriesModeToUid entriesScrollerConfigDefault entriesScrollerConfigs @@ -402,8 +400,6 @@ failMsgs fakeErrorRate falsy fd -feaa -fecd FeedType FetchResult FetchState @@ -744,6 +740,7 @@ navMe navSpacer ndid ndiff +needsConnection nestedId newCallback NewClass @@ -891,6 +888,8 @@ paddingTop padEnd padStart PageDetails +pageMutationHandler +pageMutationObserver pageService PagesToDo pageStyle diff --git a/linkedin-tool.user.js b/linkedin-tool.user.js index d24ff89c..c780b211 100644 --- a/linkedin-tool.user.js +++ b/linkedin-tool.user.js @@ -1313,6 +1313,9 @@ this.#containersMutationObserver = new MutationObserver( this.#containersMutationHandler ); + this.#pageMutationObserver = new MutationObserver( + this.#pageMutationHandler + ); this.#logger = new NH.base.Logger(`{${this.#name}}`); this.logger.log('Scroller constructed', this); @@ -1557,6 +1560,9 @@ const me = this.activate.name; this.logger.entered(me); + this.#pageMutationObserver.observe( + document.body, {childList: true, subtree: true} + ); await this.#startContainers(); // The logging statement is useful for debugging. Keep it. this.logger.log('watcher:', await this.#currentItemWatcher()); @@ -1573,6 +1579,7 @@ * @fires 'deactivate' */ deactivate() { + this.#pageMutationObserver.disconnect(); this.#mutationDispatcher.off('attributes', this.#attributesHandler); this.#mutationDispatcher.off('childList', this.#monitorConnectedness); this.#stopContainers(); @@ -1636,6 +1643,7 @@ #name #observeAttributes #onClickElements = new Set(); + #pageMutationObserver #selectors #snapToTop #stackTrace @@ -1758,6 +1766,21 @@ return height; } + #pageMutationHandler = async () => { + const me = this.#pageMutationHandler.name; + this.logger.entered(me, this.#containers); + + const needsConnection = this.#containers.values() + .some(x => !x.isConnected); + if (needsConnection) { + this.logger.log('restarting containers'); + await this.activate(); + this.logger.log('containers restarted'); + } + + this.logger.leaving(me); + } + /** * @param {MutationRecord[]} records - Standard mutation records. * @fires 'childList' @@ -1810,9 +1833,6 @@ const me = this.#getItems.name; this.logger.entered(me); - // TODO(#372): Reenable the cache - this.#itemCache = null; - if (!this.#itemCache) { // This needs to be ordered, so does not use #containers. const items = []; From 632bf3ab6e040d72006d4c56e963499e586932e0 Mon Sep 17 00:00:00 2001 From: Mike Castle Date: Wed, 22 Jul 2026 15:30:37 -0700 Subject: [PATCH 09/10] Update release notes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ␄ --- linkedin-tool.user.js | 49 +++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/linkedin-tool.user.js b/linkedin-tool.user.js index c780b211..919be824 100644 --- a/linkedin-tool.user.js +++ b/linkedin-tool.user.js @@ -174,9 +174,6 @@ ish('209', 'Support **SearchResultsPeople** view', '2026-07-09'), ish('236', 'Support **Events** page', '2026-07-10'), ish('240', 'Update top margin for `Scroller`s dynamically', '2026-04-30'), - ish( - '251', 'Normalize the `uniqueFooIdentifier()` functions', '2026-06-22' - ), ish( '295', 'Navigating from *Style-2* page to *Style-1* page breaks LIT', @@ -240,6 +237,37 @@ ]; const globalNewsContent = [ + { + date: '2026-07-22', + issues: ['372'], + subject: 'Additional monitoring for containers and enable the item' + + ' cache', + }, + { + date: '2026-07-22', + issues: ['209'], + subject: 'Update the current page navigation selector', + }, + { + date: '2026-07-22', + issues: ['372'], + subject: 'Replace a one line log with intro/outro', + }, + { + date: '2026-07-22', + issues: ['372'], + subject: 'Bring back a recently deleted logging statement', + }, + { + date: '2026-07-22', + issues: ['352'], + subject: 'Make `UidMode` an instance variable', + }, + { + date: '2026-07-22', + issues: ['302'], + subject: 'Update `UidMode`s for the *Activity* section', + }, { date: '2026-07-21', issues: [''], @@ -821,21 +849,6 @@ issues: ['240'], subject: 'Fix a couple of docstrings missed in recent changes', }, - { - date: '2026-06-22', - issues: ['352'], - subject: 'Factor out transient `Scroller` initialization', - }, - { - date: '2026-06-22', - issues: ['352'], - subject: 'Split a function missed in b1f9649', - }, - { - date: '2026-06-22', - issues: ['240'], - subject: 'Migrate `Notifications` to `StyleService`', - }, ]; /** From 5fe07953c14e6d3dac135686734651c872e5312c Mon Sep 17 00:00:00 2001 From: Mike Castle Date: Wed, 22 Jul 2026 15:31:22 -0700 Subject: [PATCH 10/10] Bump version number. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ␄ --- linkedin-tool.user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linkedin-tool.user.js b/linkedin-tool.user.js index 919be824..c73f994a 100644 --- a/linkedin-tool.user.js +++ b/linkedin-tool.user.js @@ -4,7 +4,7 @@ // @match https://www.linkedin.com/* // @inject-into content // @noframes -// @version 80 +// @version 81 // @author Mike Castle // @description Minor enhancements to LinkedIn. Mostly just hotkeys. // @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html