From abc19b5641356b77497158a034d5600eb3c7f442 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 18 Jun 2026 11:35:14 +0200 Subject: [PATCH] =?UTF-8?q?refactor(linkedin-studio):=20M0-6=20=E2=80=94?= =?UTF-8?q?=20quick-import.mjs=20via=20getDataRoot=20+=20fix=20printed=20p?= =?UTF-8?q?ath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/scripts/__tests__/quick-import.test.mjs | 45 ++++++++ hooks/scripts/quick-import.mjs | 107 ++++++++++-------- 2 files changed, 107 insertions(+), 45 deletions(-) create mode 100644 hooks/scripts/__tests__/quick-import.test.mjs diff --git a/hooks/scripts/__tests__/quick-import.test.mjs b/hooks/scripts/__tests__/quick-import.test.mjs new file mode 100644 index 0000000..726ff08 --- /dev/null +++ b/hooks/scripts/__tests__/quick-import.test.mjs @@ -0,0 +1,45 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// M0-6: quick-import must resolve its exports dir under the EXTERNAL data root, +// and the manual import command it prints must no longer pin ANALYTICS_ROOT to +// the in-plugin tree. The module's executable body (browser open + poll timer) +// sits behind a standalone guard, so importing it for tests is side-effect-free. +// LINKEDIN_STUDIO_DATA is stubbed before the dynamic import (EXPORTS_DIR binds at +// load). Pattern: data-root.test.mjs env-stub + queue-manager.test.mjs. +describe('quick-import — external data root + de-pinned command (M0-6)', () => { + let dataDir, mod; + const saved = { LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA }; + + before(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'lis-quickimport-')); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + mod = await import('../quick-import.mjs'); + }); + + after(() => { + if (dataDir && existsSync(dataDir)) rmSync(dataDir, { recursive: true, force: true }); + if (saved.LINKEDIN_STUDIO_DATA === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = saved.LINKEDIN_STUDIO_DATA; + }); + + test('EXPORTS_DIR resolves under /analytics/exports', () => { + assert.equal(mod.EXPORTS_DIR, join(dataDir, 'analytics', 'exports')); + }); + + test('the printed import command no longer pins a bare in-plugin assets/analytics path', () => { + const cmd = mod.buildImportCommand('/plugin/root', 'export.csv'); + assert.ok( + !cmd.includes('assets/analytics'), + 'manual command must not pin ANALYTICS_ROOT to the in-plugin assets/analytics tree', + ); + assert.ok( + cmd.includes('scripts/analytics/src/cli.ts'), + 'manual command must still invoke the in-plugin tsx CLI', + ); + assert.ok(cmd.includes('export.csv')); + }); +}); diff --git a/hooks/scripts/quick-import.mjs b/hooks/scripts/quick-import.mjs index 8dde107..2fa5ea7 100644 --- a/hooks/scripts/quick-import.mjs +++ b/hooks/scripts/quick-import.mjs @@ -6,16 +6,25 @@ import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync } from 'node import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { exec } from 'node:child_process'; +import { getDataRoot } from './data-root.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); const HOME = process.env.HOME || process.env.USERPROFILE || ''; -const EXPORTS_DIR = join(PLUGIN_ROOT, 'assets', 'analytics', 'exports'); +const EXPORTS_DIR = join(getDataRoot('analytics'), 'exports'); const DOWNLOADS_DIR = join(HOME, 'Downloads'); const POLL_INTERVAL = 3000; const MAX_WAIT = 300000; // 5 minutes -mkdirSync(EXPORTS_DIR, { recursive: true }); +// Manual import command we print for the user. It must NOT pin ANALYTICS_ROOT to +// the in-plugin tree anymore — the CLI defaults to the external data root +// (storage.ts:getDataRoot), so the data path is omitted. The plugin path stays +// only for the in-plugin tsx executable (cli.ts), which is bundled, not data. +export function buildImportCommand(pluginRoot, filename) { + return `node --import tsx "${pluginRoot}/scripts/analytics/src/cli.ts" import "${filename}"`; +} + +export { EXPORTS_DIR }; // Snapshot existing CSV files function getCsvFiles() { @@ -34,53 +43,61 @@ function openUrl(url) { exec(`${cmd} "${url}"`, () => {}); } -const beforeFiles = new Set(getCsvFiles()); +function main() { + mkdirSync(EXPORTS_DIR, { recursive: true }); -console.log('Opening LinkedIn Analytics in your browser...'); -openUrl('https://www.linkedin.com/analytics/creator/content/'); + const beforeFiles = new Set(getCsvFiles()); -console.log('\nInstructions:'); -console.log(' 1. Click \'Export\' (top right) in LinkedIn Analytics'); -console.log(' 2. LinkedIn will download a CSV to ~/Downloads'); -console.log(' 3. This script will detect it automatically\n'); -console.log('Watching ~/Downloads for new CSV files (max 5 minutes)...\n'); + console.log('Opening LinkedIn Analytics in your browser...'); + openUrl('https://www.linkedin.com/analytics/creator/content/'); -let elapsed = 0; -const timer = setInterval(() => { - elapsed += POLL_INTERVAL; + console.log('\nInstructions:'); + console.log(' 1. Click \'Export\' (top right) in LinkedIn Analytics'); + console.log(' 2. LinkedIn will download a CSV to ~/Downloads'); + console.log(' 3. This script will detect it automatically\n'); + console.log('Watching ~/Downloads for new CSV files (max 5 minutes)...\n'); - const currentFiles = getCsvFiles(); - const newFiles = currentFiles.filter(f => !beforeFiles.has(f)); + let elapsed = 0; + const timer = setInterval(() => { + elapsed += POLL_INTERVAL; - for (const filename of newFiles) { - const filePath = join(DOWNLOADS_DIR, filename); - try { - const age = (Date.now() - statSync(filePath).mtime.getTime()) / 1000; - if (/linkedin|analytics|content|export/i.test(filename) || age < 60) { - console.log(`Detected: ${filename}`); - copyFileSync(filePath, join(EXPORTS_DIR, filename)); - console.log(`Copied to: ${EXPORTS_DIR}/${filename}\n`); - console.log('File is ready for import. Run:'); - console.log(' /linkedin:import\n'); - console.log('Or import directly with:'); - console.log(` ANALYTICS_ROOT="${PLUGIN_ROOT}/assets/analytics" node --import tsx "${PLUGIN_ROOT}/scripts/analytics/src/cli.ts" import "${filename}"`); - clearInterval(timer); - process.exit(0); - } - } catch { /* ignore */ } - } + const currentFiles = getCsvFiles(); + const newFiles = currentFiles.filter(f => !beforeFiles.has(f)); - if (elapsed % 15000 === 0) { - const remaining = Math.floor((MAX_WAIT - elapsed) / 60000); - console.log(` Still waiting... (${remaining}m remaining)`); - } + for (const filename of newFiles) { + const filePath = join(DOWNLOADS_DIR, filename); + try { + const age = (Date.now() - statSync(filePath).mtime.getTime()) / 1000; + if (/linkedin|analytics|content|export/i.test(filename) || age < 60) { + console.log(`Detected: ${filename}`); + copyFileSync(filePath, join(EXPORTS_DIR, filename)); + console.log(`Copied to: ${EXPORTS_DIR}/${filename}\n`); + console.log('File is ready for import. Run:'); + console.log(' /linkedin:import\n'); + console.log('Or import directly with:'); + console.log(` ${buildImportCommand(PLUGIN_ROOT, filename)}`); + clearInterval(timer); + process.exit(0); + } + } catch { /* ignore */ } + } - if (elapsed >= MAX_WAIT) { - console.log('\nTimed out after 5 minutes. No new CSV detected.\n'); - console.log('You can manually copy the file:'); - console.log(` mv ~/Downloads/.csv ${EXPORTS_DIR}/`); - console.log(' /linkedin:import'); - clearInterval(timer); - process.exit(1); - } -}, POLL_INTERVAL); + if (elapsed % 15000 === 0) { + const remaining = Math.floor((MAX_WAIT - elapsed) / 60000); + console.log(` Still waiting... (${remaining}m remaining)`); + } + + if (elapsed >= MAX_WAIT) { + console.log('\nTimed out after 5 minutes. No new CSV detected.\n'); + console.log('You can manually copy the file:'); + console.log(` mv ~/Downloads/.csv ${EXPORTS_DIR}/`); + console.log(' /linkedin:import'); + clearInterval(timer); + process.exit(1); + } + }, POLL_INTERVAL); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +}