diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e0e175..4338c5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: run: node scripts/test.js - name: Run v1.4 single-surface tests run: node scripts/test-v14.js + - name: Run v1.5 sub-daily automation tests + run: node scripts/test-v15.js - name: Run sheet and scheduler checks run: node scripts/test-scheduler.js - name: Run migration and storage checks diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a32ad8..e13de5c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,7 +27,7 @@ User's Google Sheet +-- job persistence +-- sync engine +-- optional playlist heartbeat - +-- one reconciled daily scheduler + +-- one reconciled scheduler +-- single sidebar application +-- read-only status + Activity views | @@ -58,14 +58,16 @@ User-facing: Internal: -- **Jobs** — hidden durable job records, including stable Job IDs, playlist IDs, behavior, automation interval, heartbeat preference and per-job telemetry. -- **Schedule** — legacy v1.3 sheet preserved/hidden on upgrade; normal v1.4 runtime does not render it. +- **Jobs** — hidden durable job records, including stable Job IDs, playlist IDs, behavior, automation frequency, heartbeat preference and per-job telemetry. +- **Schedule** — legacy v1.3 sheet preserved/hidden on upgrade; normal v1.5 runtime does not render it. + +The existing `Frequency` cell is the canonical serialized schedule. It stores values such as `Hourly`, `Every 6 hours`, `Daily`, or `Every 7 days`; no additional scheduling column is required for v1.5. ### Document Properties -- scheduler telemetry +- scheduler telemetry and current scheduler mode (`NONE`, `DAILY`, or `HOURLY`) - update-check cache/status -- latest run summary +- latest real run summary - playlist display-name cache - one tiny heartbeat phrase index per stable Job ID @@ -111,12 +113,14 @@ Runtime identity uses hidden Spotify playlist IDs, not human-readable display la Automation maps onto the existing storage model: ```text -Off → enabled=false -Daily → enabled=true, intervalDays=1 -Every N days → enabled=true, intervalDays=N +Off → enabled=false, stored Frequency retained +Hourly → enabled=true, unit=HOUR, interval=1 +Every N hours → enabled=true, unit=HOUR, interval=N (1–23) +Daily → enabled=true, unit=DAY, interval=1 +Every N days → enabled=true, unit=DAY, interval=N (1–3650) ``` -The canonical server-side Frequency parser remains responsible for validating the supported 1–3650 day interval. +The canonical server-side Frequency parser remains responsible for validation. Day-based schedules retain the existing calendar-day semantics; hour-based schedules use elapsed hours since the last successful run. ## Runtime model @@ -125,13 +129,16 @@ The canonical server-side Frequency parser remains responsible for validating th `Scheduler.reconcile()` enforces the invariant: ```text -0 automated jobs → 0 Spoti Sync triggers -1+ automated jobs → exactly 1 Spoti Sync daily trigger +0 automated jobs → 0 Spoti Sync triggers +only Daily / Every N days jobs → exactly 1 DAILY Spoti Sync trigger +any Hourly / Every N hours job present → exactly 1 HOURLY Spoti Sync trigger ``` -An already-correct single trigger is retained. Missing or duplicate Spoti Sync triggers are normalized. There are no per-job triggers. +There is never a daily and hourly Spoti Sync scheduler trigger at the same time, and there are no per-job triggers. + +The scheduler persists the selected dispatcher mode in Document Properties because Apps Script project-trigger objects identify the handler but do not expose enough recurrence metadata for Spoti Sync to reliably infer whether an existing clock trigger is hourly or daily. A single legacy v1.4 trigger with no stored mode is treated as DAILY, so an upgrade with only day-based jobs does not recreate a correct trigger. -The daily trigger invokes `spotiSyncScheduler()`, which runs enabled jobs only when their configured interval is due. +The hourly dispatcher is deliberately cheap. It loads local job state, evaluates due jobs, and exits when none are due. A no-due check does not call Spotify, append an Activity row, overwrite the last real run summary, or repaint the status sheet. Scheduler health telemetry may still be updated. ### Manual runs @@ -186,9 +193,9 @@ Normal runtime does not format or add validation to hidden Jobs, and does not re ## Migration -v1.4 upgrades the v1.3.8 Jobs schema by appending `Heartbeat Enabled` and defaults existing configured jobs to `true`. +v1.5 does not add a Jobs schema column. Existing `Daily` and `Every N days` Frequency strings continue to parse exactly as day schedules and retain their existing calendar-day behavior. -Migration preserves stable Job IDs, playlist IDs, names, behavior, frequencies, automation state and telemetry. It remains explicit/bounded: no whole-sheet `clearFormats()` and no OAuth/property reset. +The v1.4 migration protections remain in place: stable Job IDs, playlist IDs, names, behavior, frequencies, automation state, heartbeat state and telemetry are preserved. Migration remains explicit/bounded: no whole-sheet `clearFormats()` and no OAuth/property reset. The old Dashboard is renamed to Spoti Sync when safe. A conflicting unrelated user sheet named `Spoti Sync` must never be cleared; a safe status fallback is used instead. @@ -212,9 +219,9 @@ Strategies remain pure planning logic. ## Safety and resilience -- Script lock prevents overlapping playlist writes. +- Script lock prevents overlapping playlist writes, including manual and scheduled runs. - Spotify `401` can trigger token refresh/retry. -- Spotify `429` honors practical Retry-After values. +- Spotify `429` honors practical `Retry-After` values and stops rather than sleeping past the Apps Script execution budget when the requested wait is too long. - transient `5xx` responses use bounded retries. - one failed job does not stop later jobs. - heartbeat failure does not replay playlist mutations. diff --git a/CHANGELOG.md b/CHANGELOG.md index aa69a09..25fd05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to Spoti Sync are documented here. +## 1.5.0 — 2026-08-20 + +### Added + +- Added **Hourly** and **Every N hours** automation alongside the existing Off, Daily, and Every N days options. +- Hour-based intervals support 1–23 hours; 24 hours remains represented canonically as **Daily**. + +### Automation + +- Kept the single-trigger architecture. `Scheduler.reconcile()` now selects one of three states: no trigger when no jobs are automated, one daily trigger for day-only jobs, or one hourly dispatcher when any hour-based job exists. +- Mixed schedules still use only one trigger. The hourly dispatcher checks local job state first and calls Spotify only for jobs that are actually due. +- Existing single v1.4 daily triggers are retained for day-only installations instead of being recreated unnecessarily; scheduler mode is persisted in Document Properties so later reconciliations can distinguish daily and hourly cadence safely. + +### Compatibility and performance + +- Existing `Daily` and `Every N days` values keep their calendar-day semantics and require no Jobs schema migration. +- Hour-based schedules use elapsed hours since the last successful run. +- A no-due hourly scheduler wake does not fetch Spotify data, append Activity noise, overwrite the last real run summary, or repaint the status sheet. +- Preserved manual Sync now for Automation Off jobs, script locking, Exact Mirror / Append Only behavior, playlist heartbeat semantics, OAuth state, playlist IDs and stable Job IDs. + +### Tests + +- Added regression coverage for hourly parsing/bounds, elapsed-hour eligibility, mixed daily/hourly reconciliation, legacy daily-trigger retention, hourly-to-daily downgrade, duplicate-trigger normalization, no-due execution cost and existing manual/day-based behavior. +- Kept generated-sidebar boot protections, migration guards, no-`clearFormats()` protections, playlist-catalog laziness and installer-manifest checks in CI. + +### Upgrade note + +- Install the 1.5.0 bundle in the same Apps Script project, save, reload the Sheet, then choose **Spoti Sync → Open Spoti Sync**. Existing Spotify connection, Client ID, jobs, playlist IDs, heartbeat preferences and automation are preserved; no repair or manual trigger recreation is required. + ## 1.4.1 — 2026-08-16 ### Fixed diff --git a/README.md b/README.md index a110720..89194b0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Spoti Sync has one normal control surface: **Spoti Sync → Open Spoti Sync**. T - Use **Liked Songs** or a Spotify playlist as the source. - Sync into an existing playlist or create a new target playlist. - Choose **Exact Mirror** or **Append Only** behavior. -- Set **Automation: Off, Daily, or Every N days**. +- Set **Automation: Off, Hourly, Every N hours, Daily, or Every N days**. - Run any job manually with **Sync now**. - Optionally keep the target playlist description updated with Spoti Sync status. @@ -43,11 +43,13 @@ No web-app deployment, local server, Node.js installation, or `clasp` setup is r | **Source** | Liked Songs or Spotify playlist | | **Target** | Existing playlist or create a new playlist | | **Behavior** | Exact Mirror or Append Only | -| **Automation** | Off, Daily, or Every N days | +| **Automation** | Off, Hourly, Every N hours, Daily, or Every N days | **Exact Mirror** keeps the managed target membership aligned with the source. **Append Only** adds missing source tracks and never removes existing target tracks. +Spoti Sync still uses one background scheduler. If any job uses an hour-based interval, jobs are checked hourly; Spotify is contacted only for jobs that are actually due. + ## 🔐 Privacy Spoti Sync runs in your own Google Apps Script environment. Spotify access and refresh tokens are stored in Apps Script User Properties, not on a hosted Spoti Sync backend or GitHub Pages. @@ -71,6 +73,7 @@ Node.js 22+ is used only for local build and test tooling. node scripts/build.js node scripts/test.js node scripts/test-v14.js +node scripts/test-v15.js node scripts/test-scheduler.js node scripts/test-sheet-repair.js node scripts/test-job-editor.js diff --git a/docs/index.html b/docs/index.html index faeb50c..7af7014 100644 --- a/docs/index.html +++ b/docs/index.html @@ -70,9 +70,9 @@

Only the choices that matter.

SourceLiked Songs
or Spotify playlist
TargetExisting playlist
or create new
BehaviorExact Mirror
or Append Only
-
AutomationOff · Daily
Every N days
+
AutomationOff · Hourly · Every N hours
Daily · Every N days
-

Optional per job: keep the target playlist description updated with the latest successful sync time.

+

Hour-based jobs are checked hourly, but Spotify is contacted only when a job is actually due. Optional per job: keep the target playlist description updated with the latest successful sync time.

diff --git a/docs/version.json b/docs/version.json index 3c756cb..73f83f2 100644 --- a/docs/version.json +++ b/docs/version.json @@ -1,13 +1,13 @@ { "schema": 1, - "version": "1.4.1", + "version": "1.5.0", "channel": "stable", - "released_at": "2026-08-16", + "released_at": "2026-08-20", "installer_url": "https://sid.is-a.dev/Spoti-sync/#update", "changelog_url": "https://github.com/11sid11/Spoti-sync/blob/main/CHANGELOG.md", "notes": [ - "Fixes the blank v1.4 sidebar caused by a browser-global top identifier collision in generated client JavaScript.", - "Adds a visible loading state and compact runtime boot-error fallback so sidebar failures are no longer silent.", - "Adds regression coverage that parses the generated production sidebar script and guards against redeclaring the browser top global." + "Adds Hourly and Every N hours automation while keeping the existing Off, Daily, and Every N days options.", + "Keeps a single adaptive Apps Script scheduler: daily for day-only jobs, hourly when any sub-daily job exists, with Spotify contacted only for due jobs.", + "Preserves existing jobs, playlist IDs, OAuth state, day-based scheduling semantics, manual Sync now behavior, and heartbeat settings." ] } diff --git a/scripts/test-docs.js b/scripts/test-docs.js index abe05e8..471a8e0 100644 --- a/scripts/test-docs.js +++ b/scripts/test-docs.js @@ -81,6 +81,7 @@ const requiredCurrentFlow = [ 'Spoti Sync → Open Spoti Sync', 'Exact Mirror', 'Append Only', + 'Every N hours', 'Every N days' ]; diff --git a/scripts/test-job-editor.js b/scripts/test-job-editor.js index 2f5f76e..e9d08ba 100644 --- a/scripts/test-job-editor.js +++ b/scripts/test-job-editor.js @@ -37,7 +37,7 @@ context.SpotiSync.Auth = { getRedirectUri() { return 'https://script.google.com/macros/d/test/usercallback'; } }; context.SpotiSync.Scheduler = { - getStatus() { return { enabled: false, triggerCount: 0, automatedJobs: 0 }; } + getStatus() { return { enabled: false, mode: 'NONE', triggerCount: 0, automatedJobs: 0 }; } }; context.SpotiSync.UpdateChecker = { getCachedStatus() { return {}; }, @@ -81,19 +81,50 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), ' assert.strictEqual(JobEditor._cleanStoredLabel('Open playlist ↗'), ''); })(); -(function testAutomationModelIsOnlyOffDailyOrInterval() { - assert.strictEqual(JobEditor._automationForJob({ enabled: false, intervalDays: 10 }), 'OFF'); - assert.strictEqual(JobEditor._automationForJob({ enabled: true, intervalDays: 1 }), 'DAILY'); - assert.strictEqual(JobEditor._automationForJob({ enabled: true, intervalDays: 21 }), 'INTERVAL'); +(function testAutomationModelSupportsHoursAndPreservesDays() { + assert.strictEqual(JobEditor._automationForJob({ enabled: false, frequencyUnit: 'DAY', frequencyInterval: 10 }), 'OFF'); + assert.strictEqual(JobEditor._automationForJob({ enabled: true, frequencyUnit: 'HOUR', frequencyInterval: 1 }), 'HOURLY'); + assert.strictEqual(JobEditor._automationForJob({ enabled: true, frequencyUnit: 'HOUR', frequencyInterval: 6 }), 'HOURS'); + assert.strictEqual(JobEditor._automationForJob({ enabled: true, frequencyUnit: 'DAY', frequencyInterval: 1 }), 'DAILY'); + assert.strictEqual(JobEditor._automationForJob({ enabled: true, frequencyUnit: 'DAY', frequencyInterval: 21 }), 'DAYS'); - assert.strictEqual(JobEditor._intervalForPayload({ automation: 'OFF', intervalDays: 21 }), 21); - assert.strictEqual(JobEditor._intervalForPayload({ automation: 'DAILY', intervalDays: 99 }), 1); - assert.strictEqual(JobEditor._intervalForPayload({ automation: 'INTERVAL', intervalDays: 21 }), 21); - assert.throws(() => JobEditor._intervalForPayload({ automation: 'INTERVAL', intervalDays: 0 }), /1 to 3650/); - assert.throws(() => JobEditor._intervalForPayload({ automation: 'INTERVAL', intervalDays: 3651 }), /1 to 3650/); + let frequency = JobEditor._frequencyForPayload({ automation: 'OFF', existingFrequency: 'Every 21 days' }); + assert.strictEqual(frequency.unit, 'DAY'); + assert.strictEqual(frequency.interval, 21); + assert.strictEqual(frequency.label, 'Every 21 days'); + + frequency = JobEditor._frequencyForPayload({ automation: 'HOURLY' }); + assert.strictEqual(frequency.unit, 'HOUR'); + assert.strictEqual(frequency.interval, 1); + assert.strictEqual(frequency.label, 'Hourly'); + + frequency = JobEditor._frequencyForPayload({ automation: 'HOURS', intervalHours: 6 }); + assert.strictEqual(frequency.unit, 'HOUR'); + assert.strictEqual(frequency.interval, 6); + assert.strictEqual(frequency.label, 'Every 6 hours'); + + frequency = JobEditor._frequencyForPayload({ automation: 'DAILY' }); + assert.strictEqual(frequency.unit, 'DAY'); + assert.strictEqual(frequency.interval, 1); + assert.strictEqual(frequency.label, 'Daily'); + + frequency = JobEditor._frequencyForPayload({ automation: 'DAYS', intervalDays: 21 }); + assert.strictEqual(frequency.unit, 'DAY'); + assert.strictEqual(frequency.interval, 21); + assert.strictEqual(frequency.label, 'Every 21 days'); + + // A stale v1.4 sidebar that remained open during an update is still safe. + frequency = JobEditor._frequencyForPayload({ automation: 'INTERVAL', intervalDays: 10 }); + assert.strictEqual(frequency.unit, 'DAY'); + assert.strictEqual(frequency.interval, 10); + + assert.throws(() => JobEditor._frequencyForPayload({ automation: 'HOURS', intervalHours: 0 }), /1 to 23/); + assert.throws(() => JobEditor._frequencyForPayload({ automation: 'HOURS', intervalHours: 24 }), /1 to 23/); + assert.throws(() => JobEditor._frequencyForPayload({ automation: 'DAYS', intervalDays: 0 }), /1 to 3650/); + assert.throws(() => JobEditor._frequencyForPayload({ automation: 'DAYS', intervalDays: 3651 }), /1 to 3650/); })(); -(function testExistingJobEditorConfigMapsEnabledToAutomationWithoutChangingIds() { +(function testExistingDayJobEditorConfigPreservesIdsAndFrequency() { const job = { jobId: 'job_keep', name: 'Archive', @@ -104,7 +135,11 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), ' targetPlaylist: 'ABCDEFGHIJKL', targetLabel: 'Archive ↗', strategy: 'APPEND', + frequencyUnit: 'DAY', + frequencyInterval: 21, + frequencyLabel: 'Every 21 days', intervalDays: 21, + intervalHours: null, heartbeatEnabled: false }; const config = JobEditor._editorConfig(job); @@ -112,10 +147,51 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), ' assert.strictEqual(config.sourcePlaylistId, '1234567890AB'); assert.strictEqual(config.targetPlaylistId, 'ABCDEFGHIJKL'); assert.strictEqual(config.automation, 'OFF'); + assert.strictEqual(config.frequency, 'Every 21 days'); assert.strictEqual(config.intervalDays, 21); assert.strictEqual(config.heartbeatEnabled, false); })(); +(function testExistingHourlyJobEditorConfigInitializesCorrectly() { + const job = { + jobId: 'job_hour', + name: 'Fast sync', + enabled: true, + sourceType: 'LIKED_SONGS', + sourcePlaylist: '', + sourceLabel: 'Liked Songs', + targetPlaylist: 'ABCDEFGHIJKL', + targetLabel: 'Fast target', + strategy: 'MIRROR', + frequencyUnit: 'HOUR', + frequencyInterval: 6, + frequencyLabel: 'Every 6 hours', + intervalDays: null, + intervalHours: 6, + heartbeatEnabled: true + }; + const config = JobEditor._editorConfig(job); + assert.strictEqual(config.automation, 'HOURS'); + assert.strictEqual(config.frequency, 'Every 6 hours'); + assert.strictEqual(config.intervalHours, 6); +})(); + +(function testCanonicalFrequencyParserOwnsHourlyValidation() { + let parsed = SheetStore.parseFrequency('Hourly'); + assert.strictEqual(parsed.unit, 'HOUR'); + assert.strictEqual(parsed.interval, 1); + parsed = SheetStore.parseFrequency('Every 12 hours'); + assert.strictEqual(parsed.unit, 'HOUR'); + assert.strictEqual(parsed.interval, 12); + parsed = SheetStore.parseFrequency('Daily'); + assert.strictEqual(parsed.unit, 'DAY'); + assert.strictEqual(parsed.interval, 1); + parsed = SheetStore.parseFrequency('Every 7 days'); + assert.strictEqual(parsed.unit, 'DAY'); + assert.strictEqual(parsed.interval, 7); + assert.throws(() => SheetStore.parseFrequency('Every 24 hours'), /Use Daily for 24 hours/); +})(); + (function testJobServiceOwnsCatalogButHomeDoesNotFetchIt() { const homeBlock = editorSource.slice( editorSource.indexOf('function homeModel()'), @@ -123,7 +199,7 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), ' ); const editorBlock = editorSource.slice( editorSource.indexOf('function editorModel('), - editorSource.indexOf('function intervalForPayload') + editorSource.indexOf('function frequencyForPayload') ); assert(!homeBlock.includes('getCatalog('), 'Opening app home must not load Spotify playlist catalog.'); assert(editorBlock.includes('getCatalog(false)'), 'Add/Edit may lazy-load the playlist catalog.'); @@ -143,6 +219,8 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), ' assert(uiSource.includes('YOUR JOBS') || uiSource.includes('Your jobs')); assert(uiSource.includes('+ Add job')); assert(uiSource.includes('Automation')); + assert(uiSource.includes('intervalHours')); + assert(uiSource.includes('intervalDays')); assert(uiSource.includes('Show Spoti Sync status in playlist description')); assert(uiSource.includes('Delete job')); assert(uiSource.includes('Sync now')); @@ -151,7 +229,7 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), ' (function testGeneratedSidebarBootIsBrowserSafe() { const html = context.SpotiSync.Ui._appHtml({ - version: '1.4.1', + version: '1.5.0', connected: true, clientIdHint: '', redirectUri: '', @@ -178,6 +256,18 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), ' assert(clientScript.includes('renderHome(STATE);'), 'Initial home render must still occur from the embedded model.'); })(); +(function testAutomationOptionsComeFromServerModel() { + const editorBlock = editorSource.slice( + editorSource.indexOf('function editorModel('), + editorSource.indexOf('function frequencyForPayload') + ); + ['Off', 'Hourly', 'Every N hours', 'Daily', 'Every N days'].forEach((label) => { + assert(editorBlock.includes(`label: '${label}'`), `Missing automation option: ${label}`); + }); + assert(uiSource.includes('(EDITOR.automationOptions||[]).map'), 'Sidebar should render canonical server automation options.'); + assert(!uiSource.includes('value=\\"INTERVAL\\"'), 'Legacy INTERVAL must not be exposed as a new UI option.'); +})(); + (function testJobEditorNoLongerContainsSecondSidebarImplementation() { assert(!editorSource.includes('showSidebar(')); assert(!editorSource.includes('function editorHtml(')); @@ -203,4 +293,4 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), ' assert(!uiSource.includes('spotiSyncSearchPlaylists')); })(); -console.log('v1.4 single-surface app, sidebar boot, and Job service tests passed.'); +console.log('v1.5 single-surface app, hourly automation, sidebar boot, and Job service tests passed.'); diff --git a/scripts/test-scheduler.js b/scripts/test-scheduler.js index fc45c1e..781b7e8 100644 --- a/scripts/test-scheduler.js +++ b/scripts/test-scheduler.js @@ -12,7 +12,27 @@ const entrypoints = fs.readFileSync(path.join(root, 'src', '99_Entrypoints.gs'), const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), 'utf8'); const core = fs.readFileSync(path.join(root, 'src', '00_Core.gs'), 'utf8'); -function makeContext(enabledJobs, initialTriggerCount) { +function dayJob(id, interval = 1, enabled = true) { + return { + jobId: id, + enabled, + frequencyUnit: 'DAY', + frequencyInterval: interval, + intervalDays: interval + }; +} + +function hourJob(id, interval = 1, enabled = true) { + return { + jobId: id, + enabled, + frequencyUnit: 'HOUR', + frequencyInterval: interval, + intervalHours: interval + }; +} + +function makeContext(jobs, initialTriggerCount, initialMode) { let triggerId = 0; let triggers = Array.from({ length: initialTriggerCount }, () => ({ id: ++triggerId, @@ -20,6 +40,14 @@ function makeContext(enabledJobs, initialTriggerCount) { })); let creates = 0; let deletes = 0; + let summaryRefreshes = 0; + let updateChecks = 0; + const createdCadences = []; + const documentStatus = {}; + if (initialMode !== undefined && initialMode !== null) { + documentStatus.SCHEDULER_MODE = initialMode; + } + const context = vm.createContext({ console, Date, Object, Array, String, Number, Boolean, Math, JSON, RegExp, Error, Session: { getScriptTimeZone() { return 'UTC'; } }, @@ -36,12 +64,23 @@ function makeContext(enabledJobs, initialTriggerCount) { }, newTrigger(handler) { assert.strictEqual(handler, 'spotiSyncScheduler'); + let cadence = ''; return { timeBased() { return this; }, - everyDays(days) { assert.strictEqual(days, 1); return this; }, + everyDays(days) { + assert.strictEqual(days, 1); + cadence = 'DAILY'; + return this; + }, + everyHours(hours) { + assert.strictEqual(hours, 1); + cadence = 'HOURLY'; + return this; + }, atHour() { return this; }, create() { creates += 1; + createdCadences.push(cadence); const trigger = { id: ++triggerId, getHandlerFunction() { return 'spotiSyncScheduler'; } @@ -53,73 +92,158 @@ function makeContext(enabledJobs, initialTriggerCount) { } } }); + context.SpotiSync = { - Constants: { DEFAULT_SCHEDULER_HOUR: 3 }, + Constants: { + DEFAULT_SCHEDULER_HOUR: 3, + FREQUENCY_UNITS: { HOUR: 'HOUR', DAY: 'DAY' } + }, Core: { + trim(value) { return value === null || value === undefined ? '' : String(value).trim(); }, nowIso() { return new Date().toISOString(); }, safeErrorMessage(error) { return String(error && error.message || error); } }, Storage: { - getDocumentStatus() { return {}; }, - setDocumentStatus() {} + getDocumentStatus() { return { ...documentStatus }; }, + setDocumentStatus(values) { Object.assign(documentStatus, values); } }, SheetStore: { - getJobs() { - return Array.from({ length: enabledJobs }, (_, index) => ({ jobId: `job_${index}`, enabled: true })); - }, - refreshSummary() {} + getJobs() { return jobs.slice(); }, + refreshSummary() { summaryRefreshes += 1; } }, - UpdateChecker: { check() {} }, + UpdateChecker: { check() { updateChecks += 1; } }, SyncEngine: { runDue() { return { status: 'Success' }; } } }; vm.runInContext(schedulerSource, context, { filename: '80_Scheduler.gs' }); + return { Scheduler: context.SpotiSync.Scheduler, - stats() { return { creates, deletes, triggerCount: triggers.length }; } + setRunDueResult(result) { context.SpotiSync.SyncEngine.runDue = () => result; }, + stats() { + return { + creates, + deletes, + triggerCount: triggers.length, + createdCadences: createdCadences.slice(), + mode: documentStatus.SCHEDULER_MODE || '', + summaryRefreshes, + updateChecks + }; + } }; } (function testZeroAutomatedJobsRemovesTriggers() { - const env = makeContext(0, 2); + const env = makeContext([], 2, 'DAILY'); const result = env.Scheduler.reconcile({ refresh: false }); assert.strictEqual(result.enabled, false); + assert.strictEqual(result.mode, 'NONE'); assert.strictEqual(env.stats().triggerCount, 0); assert.strictEqual(env.stats().creates, 0); assert.strictEqual(env.stats().deletes, 2); + assert.strictEqual(env.stats().mode, 'NONE'); })(); -(function testOneCorrectTriggerIsNotRecreated() { - const env = makeContext(2, 1); +(function testLegacySingleDailyTriggerIsRetainedAndMarked() { + const env = makeContext([dayJob('job_day')], 1, null); const result = env.Scheduler.reconcile({ refresh: false }); assert.strictEqual(result.enabled, true); + assert.strictEqual(result.mode, 'DAILY'); assert.strictEqual(result.changed, false); assert.strictEqual(env.stats().triggerCount, 1); assert.strictEqual(env.stats().creates, 0); assert.strictEqual(env.stats().deletes, 0); + assert.strictEqual(env.stats().mode, 'DAILY'); })(); -(function testMissingTriggerIsCreatedOnce() { - const env = makeContext(1, 0); +(function testMissingDailyTriggerIsCreatedOnce() { + const env = makeContext([dayJob('job_day', 7)], 0, 'NONE'); const result = env.Scheduler.reconcile({ refresh: false }); - assert.strictEqual(result.enabled, true); + assert.strictEqual(result.mode, 'DAILY'); assert.strictEqual(result.changed, true); assert.strictEqual(env.stats().triggerCount, 1); - assert.strictEqual(env.stats().creates, 1); + assert.deepStrictEqual(env.stats().createdCadences, ['DAILY']); })(); -(function testDuplicateTriggersNormalizeToOne() { - const env = makeContext(3, 3); +(function testHourlyJobCreatesOneHourlyDispatcher() { + const env = makeContext([hourJob('job_hour', 6)], 0, 'NONE'); const result = env.Scheduler.reconcile({ refresh: false }); - assert.strictEqual(result.enabled, true); + assert.strictEqual(result.mode, 'HOURLY'); + assert.strictEqual(result.triggerCount, 1); + assert.deepStrictEqual(env.stats().createdCadences, ['HOURLY']); +})(); + +(function testMixedSchedulesUseOneHourlyDispatcher() { + const env = makeContext([ + hourJob('job_hour', 12), + dayJob('job_daily', 1), + dayJob('job_weekly', 7) + ], 0, 'NONE'); + const result = env.Scheduler.reconcile({ refresh: false }); + assert.strictEqual(result.mode, 'HOURLY'); + assert.strictEqual(result.automatedJobs, 3); + assert.strictEqual(env.stats().triggerCount, 1); + assert.deepStrictEqual(env.stats().createdCadences, ['HOURLY']); +})(); + +(function testCorrectHourlyTriggerIsNotRecreated() { + const env = makeContext([hourJob('job_hour', 2)], 1, 'HOURLY'); + const result = env.Scheduler.reconcile({ refresh: false }); + assert.strictEqual(result.changed, false); + assert.strictEqual(env.stats().creates, 0); + assert.strictEqual(env.stats().deletes, 0); +})(); + +(function testDuplicateTriggersNormalizeToOneDesiredCadence() { + const env = makeContext([hourJob('job_hour', 8)], 3, 'HOURLY'); + const result = env.Scheduler.reconcile({ refresh: false }); + assert.strictEqual(result.mode, 'HOURLY'); assert.strictEqual(env.stats().triggerCount, 1); assert.strictEqual(env.stats().deletes, 3); assert.strictEqual(env.stats().creates, 1); + assert.deepStrictEqual(env.stats().createdCadences, ['HOURLY']); +})(); + +(function testRemovingFinalHourlyJobDowngradesToDaily() { + const jobs = [hourJob('job_hour', 6), dayJob('job_day', 7)]; + const env = makeContext(jobs, 1, 'HOURLY'); + jobs.splice(0, 1); + const result = env.Scheduler.reconcile({ refresh: false }); + assert.strictEqual(result.mode, 'DAILY'); + assert.strictEqual(env.stats().deletes, 1); + assert.strictEqual(env.stats().creates, 1); + assert.deepStrictEqual(env.stats().createdCadences, ['DAILY']); +})(); + +(function testDisabledHourlyJobDoesNotUpgradeDispatcher() { + const env = makeContext([hourJob('manual_hourly', 1, false), dayJob('job_day', 1)], 1, 'DAILY'); + const result = env.Scheduler.reconcile({ refresh: false }); + assert.strictEqual(result.mode, 'DAILY'); + assert.strictEqual(result.changed, false); +})(); + +(function testNoDueHourlyWakeDoesNotRefreshSummary() { + const env = makeContext([hourJob('job_hour', 6)], 1, 'HOURLY'); + env.setRunDueResult({ status: 'No jobs due' }); + const result = env.Scheduler.runDue(); + assert.strictEqual(result.status, 'No jobs due'); + assert.strictEqual(env.stats().summaryRefreshes, 0); + assert.strictEqual(env.stats().updateChecks, 1); +})(); + +(function testCompletedDueRunRefreshesSummary() { + const env = makeContext([hourJob('job_hour', 1)], 1, 'HOURLY'); + env.setRunDueResult({ status: 'Success' }); + env.Scheduler.runDue(); + assert.strictEqual(env.stats().summaryRefreshes, 1); })(); -(function testOneDailySchedulerArchitectureRemains() { +(function testOneAdaptiveSchedulerArchitectureRemains() { assert(schedulerSource.includes('.everyDays(1)')); + assert(schedulerSource.includes('.everyHours(1)')); assert(schedulerSource.includes("var HANDLER = 'spotiSyncScheduler'")); - assert(!schedulerSource.includes('job.jobId') && !schedulerSource.includes('newTrigger(job'), 'Scheduler must not create per-job triggers.'); + assert(schedulerSource.includes("HOURLY: 'HOURLY'")); + assert(!schedulerSource.includes('newTrigger(job'), 'Scheduler must not create per-job triggers.'); assert(entrypoints.includes('SpotiSync.Scheduler.runDue();'), 'Clock trigger must route through Scheduler telemetry.'); })(); @@ -140,4 +264,4 @@ function makeContext(enabledJobs, initialTriggerCount) { ['Job', 'Source', 'Target', 'Behavior', 'Automation', 'Last sync', 'Status'].forEach((label) => assert(sheetViews.includes(`'${label}'`))); })(); -console.log('v1.4 scheduler reconciliation and visible-sheet model checks passed.'); +console.log('v1.5 adaptive scheduler reconciliation and visible-sheet model checks passed.'); diff --git a/scripts/test-sheet-repair.js b/scripts/test-sheet-repair.js index e76a26e..14c2877 100644 --- a/scripts/test-sheet-repair.js +++ b/scripts/test-sheet-repair.js @@ -88,6 +88,7 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), ' assert.strictEqual(parsed.jobs.length, 1); assert.strictEqual(parsed.jobs[0].enabled, false); assert.strictEqual(parsed.jobs[0].strategy, 'APPEND'); + assert.strictEqual(parsed.jobs[0].frequencyUnit, 'DAY'); assert.strictEqual(parsed.jobs[0].intervalDays, 21); assert.strictEqual(parsed.jobs[0].heartbeatEnabled, false); assert.strictEqual(SheetStore.getAutomationLabel(parsed.jobs[0]), 'Off'); @@ -100,13 +101,34 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), ' 'Every 14 days', 'Every 30 days', 'Every 60 days', 'Every 90 days'] ); assert.deepStrictEqual(Array.from(SheetStore.behaviorOptions()), ['Exact Mirror', 'Append Only']); - assert.strictEqual(SheetStore._parseFrequency('Daily'), 1); - assert.strictEqual(SheetStore._parseFrequency('Every 21 days'), 21); + + let parsed = SheetStore._parseFrequency('Daily'); + assert.strictEqual(parsed.unit, 'DAY'); + assert.strictEqual(parsed.interval, 1); + assert.strictEqual(parsed.label, 'Daily'); + + parsed = SheetStore._parseFrequency('Every 21 days'); + assert.strictEqual(parsed.unit, 'DAY'); + assert.strictEqual(parsed.interval, 21); + assert.strictEqual(parsed.label, 'Every 21 days'); + + parsed = SheetStore._parseFrequency('Hourly'); + assert.strictEqual(parsed.unit, 'HOUR'); + assert.strictEqual(parsed.interval, 1); + assert.strictEqual(parsed.label, 'Hourly'); + + parsed = SheetStore._parseFrequency('Every 6 hours'); + assert.strictEqual(parsed.unit, 'HOUR'); + assert.strictEqual(parsed.interval, 6); + assert.strictEqual(parsed.label, 'Every 6 hours'); + + assert.throws(() => SheetStore._parseFrequency('Every 0 hours'), /1 to 23/); + assert.throws(() => SheetStore._parseFrequency('Every 24 hours'), /Use Daily for 24 hours/); assert.throws(() => SheetStore._parseFrequency('Every 0 days'), /1 to 3650/); assert.throws(() => SheetStore._parseFrequency('Every 3651 days'), /1 to 3650/); })(); -(function testJobsAreStorageOnlyInV14() { +(function testJobsAreStorageOnlyInV15() { assert(!sheetViews.includes('requireCheckbox()'), 'SheetViews must not build Jobs configuration checkboxes.'); assert(!sheetViews.includes('requireValueInList('), 'SheetViews must not build Jobs configuration dropdowns.'); assert(!sheetViews.includes('refreshJobsStatus'), 'Jobs presentation refresh must be removed.'); @@ -129,4 +151,4 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), ' assert(replacement.indexOf('.setValues(values);') < replacement.lastIndexOf('.clearContent();')); })(); -console.log('v1.4 storage migration and status-only Sheet ownership checks passed.'); +console.log('v1.5 storage migration, frequency ownership, and status-only Sheet checks passed.'); diff --git a/scripts/test-update-checker.js b/scripts/test-update-checker.js index 5fabdb5..2cb82ec 100644 --- a/scripts/test-update-checker.js +++ b/scripts/test-update-checker.js @@ -12,7 +12,7 @@ let fetchCount = 0; let responseStatus = 200; let responseBody = { schema: 1, - version: '1.4.2', + version: '1.5.1', channel: 'stable', released_at: '2026-09-01', installer_url: 'https://example.com/update', @@ -71,7 +71,7 @@ const { UpdateChecker, VERSION } = context.SpotiSync; const status = UpdateChecker.check({ force: true }); assert.strictEqual(fetchCount, 1); assert.strictEqual(status.currentVersion, VERSION); - assert.strictEqual(status.latestVersion, '1.4.2'); + assert.strictEqual(status.latestVersion, '1.5.1'); assert.strictEqual(status.updateAvailable, true); assert.strictEqual(status.checkStatus, 'Update available'); assert.strictEqual(status.installerUrl, 'https://example.com/update'); @@ -80,15 +80,15 @@ const { UpdateChecker, VERSION } = context.SpotiSync; (function testNormalCheckUsesDailyCache() { const status = UpdateChecker.check({ force: false }); assert.strictEqual(fetchCount, 1, 'Fresh cached status must avoid another GitHub request.'); - assert.strictEqual(status.latestVersion, '1.4.2'); + assert.strictEqual(status.latestVersion, '1.5.1'); })(); (function testCachedAvailabilityRecomputesAfterCodeUpgrade() { const installedVersion = context.SpotiSync.VERSION; - context.SpotiSync.VERSION = '1.4.2'; + context.SpotiSync.VERSION = '1.5.1'; const status = UpdateChecker.getCachedStatus(); - assert.strictEqual(status.currentVersion, '1.4.2'); - assert.strictEqual(status.latestVersion, '1.4.2'); + assert.strictEqual(status.currentVersion, '1.5.1'); + assert.strictEqual(status.latestVersion, '1.5.1'); assert.strictEqual(status.updateAvailable, false); assert.strictEqual(status.checkStatus, 'Up to date'); context.SpotiSync.VERSION = installedVersion; @@ -126,7 +126,7 @@ const { UpdateChecker, VERSION } = context.SpotiSync; responseStatus = 200; responseBody = { ...responseBody, - version: '1.4.2' + version: '1.5.1' }; documentStatus.UPDATE_LAST_CHECK_AT = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); const status = UpdateChecker.check({ force: false }); diff --git a/scripts/test-v14.js b/scripts/test-v14.js index dcfac3f..d0a95e8 100644 --- a/scripts/test-v14.js +++ b/scripts/test-v14.js @@ -11,6 +11,8 @@ const activity = []; let updatedSuccess = 0; let summaryRefreshes = 0; let heartbeatCalls = 0; +let runSummaries = 0; +let sourceReads = 0; const disabledJob = { rowNumber: 2, @@ -21,6 +23,8 @@ const disabledJob = { sourcePlaylist: '', targetPlaylist: '1234567890AB', strategy: 'MIRROR', + frequencyUnit: 'DAY', + frequencyInterval: 10, intervalDays: 10, heartbeatEnabled: true }; @@ -46,11 +50,11 @@ context.SpotiSync.SheetStore = { updateJobError() {}, updateConfigurationError() {}, appendActivity(entry) { activity.push(entry); }, - setRunSummary() {}, + setRunSummary() { runSummaries += 1; }, refreshSummary() { summaryRefreshes += 1; } }; context.SpotiSync.Sources = { - getForJob() { return { tracks: [{ writeUri: 'spotify:track:A' }] }; }, + getForJob() { sourceReads += 1; return { tracks: [{ writeUri: 'spotify:track:A' }] }; }, getTargetPlaylist() { return { tracks: [] }; }, invalidatePlaylist() {} }; @@ -77,12 +81,17 @@ load('70_SyncEngine.gs'); assert.strictEqual(activity.length, 1); assert.strictEqual(summaryRefreshes, 1); assert.strictEqual(heartbeatCalls, 1); + assert.strictEqual(sourceReads, 1); + assert.strictEqual(runSummaries, 1); })(); -(function testDueSchedulerPathDoesNotRunAutomationOffJob() { +(function testDueSchedulerPathDoesNotRunAutomationOffJobOrWriteNoOpRunSummary() { const result = context.SpotiSync.SyncEngine.runDue(); assert.strictEqual(result.jobs.length, 0); assert.strictEqual(result.status, 'No jobs due'); + assert.strictEqual(sourceReads, 1, 'No-due scheduler tick must not read Spotify sources.'); + assert.strictEqual(activity.length, 1, 'No-due scheduler tick must not add Activity noise.'); + assert.strictEqual(runSummaries, 1, 'No-due scheduler tick must not replace the last real run summary.'); })(); (function testTargetedManualRunDoesNotTemporarilyEnableJob() { @@ -107,4 +116,4 @@ const views = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), 'utf8' assert(!views.includes('refreshSchedule')); })(); -console.log('v1.4 manual-run, heartbeat, and one-summary-refresh tests passed.'); +console.log('v1.5 manual-run, heartbeat, no-op scheduler, and one-summary-refresh tests passed.'); diff --git a/scripts/test-v15.js b/scripts/test-v15.js new file mode 100644 index 0000000..1f32526 --- /dev/null +++ b/scripts/test-v15.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const root = path.resolve(__dirname, '..'); +const context = vm.createContext({ + console, Date, Object, Array, String, Number, Boolean, Math, JSON, RegExp, Error, + encodeURIComponent, decodeURIComponent, + SpreadsheetApp: { + getActiveSpreadsheet() { + return { getSpreadsheetTimeZone() { return 'UTC'; } }; + } + }, + Utilities: { + getUuid() { return '12345678-1234-1234-1234-123456789abc'; }, + formatDate(date, timezone, pattern) { + if (pattern === 'yyyy-MM-dd') { return date.toISOString().slice(0, 10); } + if (pattern === 'H') { return String(date.getUTCHours()); } + return date.toISOString(); + } + } +}); + +function load(filename) { + vm.runInContext(fs.readFileSync(path.join(root, 'src', filename), 'utf8'), context, { filename }); +} + +load('00_Core.gs'); +load('60_SheetStore.gs'); +load('70_SyncEngine.gs'); + +const { Core, SheetStore, SyncEngine } = context.SpotiSync; + +function jobRow(frequency, lastSuccess = '') { + return [ + true, 'Schedule test', 'Liked Songs', 'Target', 'Exact Mirror', frequency, '', '', + 'job_schedule', '', '1234567890AB', '', lastSuccess, '', 0, 0, '', true + ]; +} + +(function testDaySchedulesKeepExistingRepresentation() { + let result = SheetStore._parseJobRows([jobRow('Daily')], 2); + assert.strictEqual(result.errors.length, 0); + assert.strictEqual(result.jobs[0].frequencyUnit, 'DAY'); + assert.strictEqual(result.jobs[0].frequencyInterval, 1); + assert.strictEqual(result.jobs[0].intervalDays, 1); + assert.strictEqual(result.jobs[0].intervalHours, null); + + result = SheetStore._parseJobRows([jobRow('Every 7 days')], 2); + assert.strictEqual(result.errors.length, 0); + assert.strictEqual(result.jobs[0].frequencyUnit, 'DAY'); + assert.strictEqual(result.jobs[0].frequencyInterval, 7); + assert.strictEqual(result.jobs[0].intervalDays, 7); +})(); + +(function testHourlySchedulesUseSameFrequencyColumn() { + let result = SheetStore._parseJobRows([jobRow('Hourly')], 2); + assert.strictEqual(result.errors.length, 0); + assert.strictEqual(result.jobs[0].frequencyUnit, 'HOUR'); + assert.strictEqual(result.jobs[0].frequencyInterval, 1); + assert.strictEqual(result.jobs[0].intervalHours, 1); + assert.strictEqual(result.jobs[0].intervalDays, null); + assert.strictEqual(SheetStore.getAutomationLabel(result.jobs[0]), 'Hourly'); + + result = SheetStore._parseJobRows([jobRow('Every 6 hours')], 2); + assert.strictEqual(result.errors.length, 0); + assert.strictEqual(result.jobs[0].frequencyUnit, 'HOUR'); + assert.strictEqual(result.jobs[0].frequencyInterval, 6); + assert.strictEqual(result.jobs[0].intervalHours, 6); + assert.strictEqual(SheetStore.getAutomationLabel(result.jobs[0]), 'Every 6 hours'); +})(); + +(function testHourlyDueMathUsesElapsedHours() { + const last = new Date('2026-08-20T00:00:00Z'); + assert.strictEqual(Core.isDueByElapsedHours(last, 6, new Date('2026-08-20T05:59:59Z')), false); + assert.strictEqual(Core.isDueByElapsedHours(last, 6, new Date('2026-08-20T06:00:00Z')), true); + + const job = SheetStore._parseJobRows([jobRow('Every 6 hours', last)], 2).jobs[0]; + assert.strictEqual(SheetStore.isJobDue(job, new Date('2026-08-20T05:59:59Z')), false); + assert.strictEqual(SheetStore.isJobDue(job, new Date('2026-08-20T06:00:00Z')), true); +})(); + +(function testDayDueMathStillUsesCalendarDays() { + const job = SheetStore._parseJobRows([ + jobRow('Every 7 days', new Date('2026-08-13T23:59:00Z')) + ], 2).jobs[0]; + assert.strictEqual(SheetStore.isJobDue(job, new Date('2026-08-20T00:01:00Z')), true); +})(); + +(function testHourlyDispatcherPreservesExistingDailyWindow() { + const dayJob = SheetStore._parseJobRows([ + jobRow('Every 7 days', new Date('2026-08-13T23:59:00Z')) + ], 2).jobs[0]; + const hourJob = SheetStore._parseJobRows([ + jobRow('Every 6 hours', new Date('2026-08-19T20:00:00Z')) + ], 2).jobs[0]; + + assert.strictEqual( + SyncEngine._isDueInDispatcher(dayJob, { schedulerMode: 'HOURLY' }, new Date('2026-08-20T02:30:00Z')), + false, + 'A mixed hourly dispatcher must not pull existing day jobs forward before the legacy daily window.' + ); + assert.strictEqual( + SyncEngine._isDueInDispatcher(dayJob, { schedulerMode: 'HOURLY' }, new Date('2026-08-20T03:30:00Z')), + true + ); + assert.strictEqual( + SyncEngine._isDueInDispatcher(hourJob, { schedulerMode: 'HOURLY' }, new Date('2026-08-20T02:30:00Z')), + true, + 'Hour-based jobs are governed by elapsed hours, not the daily window.' + ); +})(); + +(function testFrequencyBoundsAndCanonicalLabels() { + assert.strictEqual(SheetStore.parseFrequency('Every 23 hours').label, 'Every 23 hours'); + assert.strictEqual(SheetStore.parseFrequency('Every 3650 days').label, 'Every 3650 days'); + assert.throws(() => SheetStore.parseFrequency('Every 0 hours'), /1 to 23/); + assert.throws(() => SheetStore.parseFrequency('Every 24 hours'), /Use Daily for 24 hours/); + assert.throws(() => SheetStore.parseFrequency('Every 0 days'), /1 to 3650/); + assert.throws(() => SheetStore.parseFrequency('Every 3651 days'), /1 to 3650/); +})(); + +(function testHotPathSafetyGuards() { + const scheduler = fs.readFileSync(path.join(root, 'src', '80_Scheduler.gs'), 'utf8'); + const syncEngine = fs.readFileSync(path.join(root, 'src', '70_SyncEngine.gs'), 'utf8'); + const sheetStore = fs.readFileSync(path.join(root, 'src', '60_SheetStore.gs'), 'utf8'); + + assert(scheduler.includes('.everyHours(1)'), 'Sub-daily jobs must use one hourly dispatcher.'); + assert(scheduler.includes('schedulerMode: mode'), 'Scheduler must tell SyncEngine which dispatcher cadence invoked it.'); + assert(!scheduler.includes('newTrigger(job'), 'No per-job triggers may be created.'); + assert(!scheduler.includes('clearFormats()')); + assert(!syncEngine.includes('clearFormats()')); + assert(!sheetStore.includes('clearFormats()')); + assert(!syncEngine.includes('SheetStore.initialize('), 'Normal sync must not invoke migration/repair.'); +})(); + +console.log('v1.5 frequency, due-time, mixed-schedule compatibility, and hot-path safety checks passed.'); diff --git a/src/00_Core.gs b/src/00_Core.gs index 5c07e39..eb5ada3 100644 --- a/src/00_Core.gs +++ b/src/00_Core.gs @@ -5,7 +5,7 @@ var SpotiSync = SpotiSync || {}; (function (ns) { 'use strict'; - ns.VERSION = '1.4.1'; + ns.VERSION = '1.5.0'; ns.Constants = Object.freeze({ APP_NAME: 'Spoti Sync', @@ -48,6 +48,10 @@ var SpotiSync = SpotiSync || {}; STRATEGIES: Object.freeze({ MIRROR: 'MIRROR', APPEND: 'APPEND' + }), + FREQUENCY_UNITS: Object.freeze({ + HOUR: 'HOUR', + DAY: 'DAY' }) }); @@ -212,6 +216,26 @@ var SpotiSync = SpotiSync || {}; return elapsed >= intervalDays; }, + isDueByElapsedHours: function (lastSuccess, intervalHours, now) { + var lastDate; + var current = now instanceof Date ? now : new Date(now); + var hours = Number(intervalHours); + + if (!lastSuccess) { + return true; + } + if (!Number.isInteger(hours) || hours < 1) { + throw new Error('Hour interval must be a positive integer.'); + } + + lastDate = lastSuccess instanceof Date ? lastSuccess : new Date(lastSuccess); + if (isNaN(lastDate.getTime()) || isNaN(current.getTime())) { + return true; + } + + return current.getTime() - lastDate.getTime() >= hours * 60 * 60 * 1000; + }, + safeErrorMessage: function (error) { var message = error && error.message ? String(error.message) : String(error || 'Unknown error'); return message diff --git a/src/60_SheetStore.gs b/src/60_SheetStore.gs index 809913f..a293a22 100644 --- a/src/60_SheetStore.gs +++ b/src/60_SheetStore.gs @@ -38,7 +38,8 @@ var SpotiSync = SpotiSync || {}; }); var FREQUENCY_PRESET_DAYS = Object.freeze([1, 2, 3, 7, 10, 14, 30, 60, 90]); - var FREQUENCY_LIMITS = Object.freeze({ MIN: 1, MAX: 3650 }); + var DAY_FREQUENCY_LIMITS = Object.freeze({ MIN: 1, MAX: 3650 }); + var HOUR_FREQUENCY_LIMITS = Object.freeze({ MIN: 1, MAX: 23 }); var BEHAVIOR_STRATEGIES = Object.freeze([ ns.Constants.STRATEGIES.MIRROR, ns.Constants.STRATEGIES.APPEND @@ -131,43 +132,100 @@ var SpotiSync = SpotiSync || {}; throw new Error('Behavior must be Exact Mirror or Append Only.'); } - function frequencyLabel(intervalDays) { - var days = Number(intervalDays); - return days === 1 ? 'Daily' : 'Every ' + days + ' days'; + function frequencyLabel(interval, unit) { + var value = Number(interval); + var frequencyUnit = unit || ns.Constants.FREQUENCY_UNITS.DAY; + + if (frequencyUnit === ns.Constants.FREQUENCY_UNITS.HOUR) { + return value === 1 ? 'Hourly' : 'Every ' + value + ' hours'; + } + return value === 1 ? 'Daily' : 'Every ' + value + ' days'; } function frequencyPresets() { - return FREQUENCY_PRESET_DAYS.map(frequencyLabel); + return FREQUENCY_PRESET_DAYS.map(function (days) { + return frequencyLabel(days, ns.Constants.FREQUENCY_UNITS.DAY); + }); } function frequencyLimits() { - return { min: FREQUENCY_LIMITS.MIN, max: FREQUENCY_LIMITS.MAX }; + return { + min: DAY_FREQUENCY_LIMITS.MIN, + max: DAY_FREQUENCY_LIMITS.MAX, + hours: { min: HOUR_FREQUENCY_LIMITS.MIN, max: HOUR_FREQUENCY_LIMITS.MAX }, + days: { min: DAY_FREQUENCY_LIMITS.MIN, max: DAY_FREQUENCY_LIMITS.MAX } + }; } function parseFrequency(value) { var normalized = ns.Core.trim(value); var match; - var days; - - if (/^daily$/i.test(normalized)) { - return 1; + var interval; + var unit; + + if (/^hourly$/i.test(normalized)) { + interval = 1; + unit = ns.Constants.FREQUENCY_UNITS.HOUR; + } else if (/^daily$/i.test(normalized)) { + interval = 1; + unit = ns.Constants.FREQUENCY_UNITS.DAY; + } else { + match = normalized.match(/^every\s+(\d+)\s+hours?$/i); + if (match) { + interval = Number(match[1]); + unit = ns.Constants.FREQUENCY_UNITS.HOUR; + } else { + match = normalized.match(/^every\s+(\d+)\s+days?$/i); + if (match) { + interval = Number(match[1]); + unit = ns.Constants.FREQUENCY_UNITS.DAY; + } else if (/^\d+$/.test(normalized)) { + // Numeric values are accepted only for legacy/internal day-based callers. + interval = Number(normalized); + unit = ns.Constants.FREQUENCY_UNITS.DAY; + } + } } - match = normalized.match(/^every\s+(\d+)\s+days?$/i); - days = match ? Number(match[1]) : Number(normalized); - if (!Number.isInteger(days) || days < FREQUENCY_LIMITS.MIN || days > FREQUENCY_LIMITS.MAX) { + + if (unit === ns.Constants.FREQUENCY_UNITS.HOUR) { + if (!Number.isInteger(interval) || interval < HOUR_FREQUENCY_LIMITS.MIN || interval > HOUR_FREQUENCY_LIMITS.MAX) { + throw new Error( + 'Hourly frequency must be Hourly or Every N hours, from ' + HOUR_FREQUENCY_LIMITS.MIN + + ' to ' + HOUR_FREQUENCY_LIMITS.MAX + ' hours. Use Daily for 24 hours.' + ); + } + } else if (unit === ns.Constants.FREQUENCY_UNITS.DAY) { + if (!Number.isInteger(interval) || interval < DAY_FREQUENCY_LIMITS.MIN || interval > DAY_FREQUENCY_LIMITS.MAX) { + throw new Error( + 'Frequency must be Daily or Every N days, from ' + DAY_FREQUENCY_LIMITS.MIN + + ' to ' + DAY_FREQUENCY_LIMITS.MAX + ' days.' + ); + } + } else { throw new Error( - 'Frequency must be Daily or Every N days, from ' + FREQUENCY_LIMITS.MIN + - ' to ' + FREQUENCY_LIMITS.MAX + ' days.' + 'Frequency must be Hourly, Every N hours, Daily, or Every N days.' ); } - return days; + + return { + unit: unit, + interval: interval, + label: frequencyLabel(interval, unit) + }; + } + + function jobFrequency(job) { + var unit = job.frequencyUnit || ns.Constants.FREQUENCY_UNITS.DAY; + var interval = Number(job.frequencyInterval || + (unit === ns.Constants.FREQUENCY_UNITS.HOUR ? job.intervalHours : job.intervalDays) || 1); + return { unit: unit, interval: interval, label: frequencyLabel(interval, unit) }; } function automationLabel(job) { if (!job.enabled) { return 'Off'; } - return frequencyLabel(job.intervalDays); + return jobFrequency(job).label; } function dateKeyFromOrdinal(ordinal) { @@ -179,6 +237,8 @@ var SpotiSync = SpotiSync || {}; var current = now || new Date(); var last; var dueOrdinal; + var frequency; + var dueAt; if (!job.enabled) { return '—'; @@ -190,7 +250,17 @@ var SpotiSync = SpotiSync || {}; if (isNaN(last.getTime())) { return 'Ready now'; } - dueOrdinal = ns.Core.calendarDayOrdinal(last, tz) + job.intervalDays; + + frequency = jobFrequency(job); + if (frequency.unit === ns.Constants.FREQUENCY_UNITS.HOUR) { + dueAt = new Date(last.getTime() + frequency.interval * 60 * 60 * 1000); + if (dueAt.getTime() <= current.getTime()) { + return 'Due now'; + } + return formatTimestamp(dueAt, 'MMM d · h:mm a'); + } + + dueOrdinal = ns.Core.calendarDayOrdinal(last, tz) + frequency.interval; if (dueOrdinal <= ns.Core.calendarDayOrdinal(current, tz)) { return 'Due now'; } @@ -241,7 +311,9 @@ var SpotiSync = SpotiSync || {}; legacySourceLabel(sourceType), ns.Core.trim(row[4]) || 'Spotify playlist', behaviorLabel(strategy), - Number.isInteger(intervalDays) && intervalDays > 0 ? frequencyLabel(intervalDays) : ns.Core.trim(row[6]), + Number.isInteger(intervalDays) && intervalDays > 0 + ? frequencyLabel(intervalDays, ns.Constants.FREQUENCY_UNITS.DAY) + : ns.Core.trim(row[6]), '', '', createJobId(), sourceType === ns.Constants.SOURCE_TYPES.PLAYLIST ? recoverLegacyPlaylistId(row[3]) : '', recoverLegacyPlaylistId(row[4]), @@ -273,7 +345,7 @@ var SpotiSync = SpotiSync || {}; if ([ns.Constants.STRATEGIES.MIRROR, ns.Constants.STRATEGIES.APPEND].indexOf(strategy) === -1) { return false; } - if (!Number.isInteger(intervalDays) || intervalDays < FREQUENCY_LIMITS.MIN || intervalDays > FREQUENCY_LIMITS.MAX) { + if (!Number.isInteger(intervalDays) || intervalDays < DAY_FREQUENCY_LIMITS.MIN || intervalDays > DAY_FREQUENCY_LIMITS.MAX) { return false; } @@ -437,7 +509,7 @@ var SpotiSync = SpotiSync || {}; ? ns.Constants.SOURCE_TYPES.PLAYLIST : ns.Constants.SOURCE_TYPES.LIKED_SONGS; var strategy = parseBehaviorLabel(row[JOB_COL.BEHAVIOR - 1]); - var intervalDays = parseFrequency(row[JOB_COL.FREQUENCY - 1]); + var frequency = parseFrequency(row[JOB_COL.FREQUENCY - 1]); var sourceLabel = ns.Core.trim(row[JOB_COL.SOURCE - 1]); var targetLabel = ns.Core.trim(row[JOB_COL.TARGET - 1]); @@ -460,7 +532,11 @@ var SpotiSync = SpotiSync || {}; targetPlaylist: targetPlaylist, targetLabel: targetLabel || 'Spotify playlist', strategy: strategy, - intervalDays: intervalDays, + frequencyUnit: frequency.unit, + frequencyInterval: frequency.interval, + frequencyLabel: frequency.label, + intervalDays: frequency.unit === ns.Constants.FREQUENCY_UNITS.DAY ? frequency.interval : null, + intervalHours: frequency.unit === ns.Constants.FREQUENCY_UNITS.HOUR ? frequency.interval : null, heartbeatEnabled: heartbeatEnabledValue(row[JOB_COL.HEARTBEAT_ENABLED - 1]), lastAttempt: row[JOB_COL.LAST_ATTEMPT - 1] || null, lastSuccess: row[JOB_COL.LAST_SUCCESS - 1] || null, @@ -525,6 +601,16 @@ var SpotiSync = SpotiSync || {}; throw new Error('Spoti Sync job not found.'); } + function frequencyFromConfig(data) { + if (ns.Core.trim(data.frequency)) { + return parseFrequency(data.frequency); + } + if (data.intervalHours !== undefined && data.intervalHours !== null && data.intervalHours !== '') { + return parseFrequency(frequencyLabel(Number(data.intervalHours), ns.Constants.FREQUENCY_UNITS.HOUR)); + } + return parseFrequency(frequencyLabel(Number(data.intervalDays), ns.Constants.FREQUENCY_UNITS.DAY)); + } + function upsertJob(config) { var data = config || {}; var sheet = ensureJobsSheet(); @@ -534,7 +620,7 @@ var SpotiSync = SpotiSync || {}; var sourcePlaylistId = ''; var targetPlaylistId; var strategy; - var intervalDays; + var frequency; var name; var sourceLabel; var targetLabel; @@ -548,7 +634,7 @@ var SpotiSync = SpotiSync || {}; strategy = BEHAVIOR_STRATEGIES.indexOf(data.strategy) !== -1 ? data.strategy : parseBehaviorLabel(data.behavior); - intervalDays = parseFrequency(data.intervalDays); + frequency = frequencyFromConfig(data); targetPlaylistId = ns.Core.parsePlaylistId(data.targetPlaylistId); if (sourceType === ns.Constants.SOURCE_TYPES.PLAYLIST) { @@ -575,7 +661,7 @@ var SpotiSync = SpotiSync || {}; row[JOB_COL.SOURCE - 1] = sourceLabel; row[JOB_COL.TARGET - 1] = targetLabel; row[JOB_COL.BEHAVIOR - 1] = behaviorLabel(strategy); - row[JOB_COL.FREQUENCY - 1] = frequencyLabel(intervalDays); + row[JOB_COL.FREQUENCY - 1] = frequency.label; row[JOB_COL.ID - 1] = jobId; row[JOB_COL.SOURCE_PLAYLIST_ID - 1] = sourcePlaylistId; row[JOB_COL.TARGET_PLAYLIST_ID - 1] = targetPlaylistId; @@ -607,6 +693,8 @@ var SpotiSync = SpotiSync || {}; behaviorOptions: behaviorOptions, frequencyPresets: frequencyPresets, frequencyLimits: frequencyLimits, + parseFrequency: parseFrequency, + formatFrequency: frequencyLabel, initialize: function (options) { var settings = options || {}; @@ -633,7 +721,11 @@ var SpotiSync = SpotiSync || {}; deleteJob: deleteJob, isJobDue: function (job, now) { - return ns.Core.isDueByCalendarDay(job.lastSuccess, job.intervalDays, now, timezone()); + var frequency = jobFrequency(job); + if (frequency.unit === ns.Constants.FREQUENCY_UNITS.HOUR) { + return ns.Core.isDueByElapsedHours(job.lastSuccess, frequency.interval, now || new Date()); + } + return ns.Core.isDueByCalendarDay(job.lastSuccess, frequency.interval, now || new Date(), timezone()); }, getSpreadsheetTimezone: timezone, @@ -715,4 +807,4 @@ var SpotiSync = SpotiSync || {}; _parseBehaviorLabel: parseBehaviorLabel, _v138JobHeaders: V138_JOB_HEADERS.slice() }; -})(SpotiSync); +})(SpotiSync); \ No newline at end of file diff --git a/src/70_SyncEngine.gs b/src/70_SyncEngine.gs index b699f45..c243f4a 100644 --- a/src/70_SyncEngine.gs +++ b/src/70_SyncEngine.gs @@ -94,13 +94,33 @@ var SpotiSync = SpotiSync || {}; return errors.filter(function (item) { return item.enabled; }); } + function isDueInDispatcher(job, options, now) { + var opts = options || {}; + var current = now || new Date(); + + if (!ns.SheetStore.isJobDue(job, current)) { + return false; + } + + if (opts.schedulerMode === 'HOURLY' && + job.frequencyUnit === ns.Constants.FREQUENCY_UNITS.DAY) { + return Number(Utilities.formatDate( + current, + ns.SheetStore.getSpreadsheetTimezone(), + 'H' + )) === ns.Constants.DEFAULT_SCHEDULER_HOUR; + } + + return true; + } + function matchingJobs(jobs, options, now) { var opts = options || {}; if (opts.jobId) { return jobs.filter(function (job) { return job.jobId === opts.jobId; }); } return jobs.filter(function (job) { - return job.enabled && (!opts.dueOnly || ns.SheetStore.isJobDue(job, now)); + return job.enabled && (!opts.dueOnly || isDueInDispatcher(job, opts, now)); }); } @@ -222,7 +242,12 @@ var SpotiSync = SpotiSync || {}; if (!jobs.length && !configurationErrors.length) { result.status = opts.dueOnly ? 'No jobs due' : 'No enabled jobs'; } - ns.SheetStore.setRunSummary(result); + // An hourly dispatcher can wake with nothing due. That is scheduler + // telemetry, not a playlist run, so do not overwrite the user's last-run + // summary on a no-op due check. + if (!(opts.dueOnly && !jobs.length && !configurationErrors.length)) { + ns.SheetStore.setRunSummary(result); + } } return result; } @@ -258,9 +283,14 @@ var SpotiSync = SpotiSync || {}; }); }, - runDue: function () { + runDue: function (options) { + var settings = options || {}; return ns.SyncEngine.withWriteLock(function () { - return runInternal({ dueOnly: true, write: true }); + return runInternal({ + dueOnly: true, + write: true, + schedulerMode: settings.schedulerMode || '' + }); }); }, @@ -278,6 +308,7 @@ var SpotiSync = SpotiSync || {}; _executeJob: executeJob, _planJob: planJob, + _isDueInDispatcher: isDueInDispatcher, _runInternal: runInternal }; -})(SpotiSync); +})(SpotiSync); \ No newline at end of file diff --git a/src/80_Scheduler.gs b/src/80_Scheduler.gs index b8c7ca6..2dcf475 100644 --- a/src/80_Scheduler.gs +++ b/src/80_Scheduler.gs @@ -4,6 +4,12 @@ var SpotiSync = SpotiSync || {}; 'use strict'; var HANDLER = 'spotiSyncScheduler'; + var MODE_KEY = 'SCHEDULER_MODE'; + var MODE = Object.freeze({ + NONE: 'NONE', + DAILY: 'DAILY', + HOURLY: 'HOURLY' + }); function schedulerTriggers() { return ScriptApp.getProjectTriggers().filter(function (trigger) { @@ -17,18 +23,55 @@ var SpotiSync = SpotiSync || {}; }); } - function createSchedulerTrigger() { - return ScriptApp.newTrigger(HANDLER) - .timeBased() - .everyDays(1) - .atHour(ns.Constants.DEFAULT_SCHEDULER_HOUR) - .create(); + function createSchedulerTrigger(mode) { + var builder = ScriptApp.newTrigger(HANDLER).timeBased(); + if (mode === MODE.HOURLY) { + return builder.everyHours(1).create(); + } + return builder.everyDays(1).atHour(ns.Constants.DEFAULT_SCHEDULER_HOUR).create(); + } + + function requiredModeForJobs(jobs) { + var automated = (jobs || []).filter(function (job) { return job.enabled; }); + if (!automated.length) { + return MODE.NONE; + } + return automated.some(function (job) { + return job.frequencyUnit === ns.Constants.FREQUENCY_UNITS.HOUR; + }) ? MODE.HOURLY : MODE.DAILY; + } + + function storedMode(triggers) { + var status = ns.Storage.getDocumentStatus(); + var mode = ns.Core.trim(status[MODE_KEY]).toUpperCase(); + if (mode === MODE.DAILY || mode === MODE.HOURLY || mode === MODE.NONE) { + return mode; + } + + // v1.4 had exactly one daily scheduler and no persisted cadence marker. + // Treat that shape as DAILY so upgrades do not churn a correct legacy trigger. + if ((triggers || schedulerTriggers()).length === 1) { + return MODE.DAILY; + } + return MODE.NONE; } - function scheduleLabel(timezone) { - var start = String(ns.Constants.DEFAULT_SCHEDULER_HOUR).padStart(2, '0') + ':00'; - var end = String((ns.Constants.DEFAULT_SCHEDULER_HOUR + 1) % 24).padStart(2, '0') + ':00'; - return 'Daily · ' + start + '–' + end + ' · ' + timezone; + function rememberMode(mode) { + var values = {}; + values[MODE_KEY] = mode; + ns.Storage.setDocumentStatus(values); + } + + function scheduleLabel(timezone, mode) { + if (mode === MODE.HOURLY) { + return 'Hourly · ' + timezone; + } + if (mode === MODE.DAILY) { + var start = String(ns.Constants.DEFAULT_SCHEDULER_HOUR).padStart(2, '0') + ':00'; + var end = String((ns.Constants.DEFAULT_SCHEDULER_HOUR + 1) % 24).padStart(2, '0') + ':00'; + return 'Daily · ' + start + '–' + end + ' · ' + timezone; + } + return 'Off'; } function recordSchedulerCheck(status, error) { @@ -55,40 +98,43 @@ var SpotiSync = SpotiSync || {}; } } - function automatedJobCount() { - return ns.SheetStore.getJobs().filter(function (job) { return job.enabled; }).length; - } - function reconcile(options) { var settings = options || {}; - var automated = automatedJobCount(); + var jobs = ns.SheetStore.getJobs(); + var automated = jobs.filter(function (job) { return job.enabled; }).length; + var desiredMode = requiredModeForJobs(jobs); var triggers = schedulerTriggers(); + var currentMode = storedMode(triggers); var changed = false; - if (!automated) { + if (desiredMode === MODE.NONE) { if (triggers.length) { deleteSchedulerTriggers(triggers); changed = true; } + rememberMode(MODE.NONE); if (settings.refresh !== false) { refreshSummaryBestEffort(); } return { enabled: false, + mode: MODE.NONE, triggerCount: 0, automatedJobs: 0, changed: changed }; } - if (triggers.length !== 1) { + if (triggers.length !== 1 || currentMode !== desiredMode) { deleteSchedulerTriggers(triggers); - createSchedulerTrigger(); + createSchedulerTrigger(desiredMode); changed = true; triggers = schedulerTriggers(); } + rememberMode(desiredMode); if (settings.refresh !== false) { refreshSummaryBestEffort(); } return { enabled: true, + mode: desiredMode, triggerCount: triggers.length || 1, automatedJobs: automated, changed: changed @@ -96,6 +142,8 @@ var SpotiSync = SpotiSync || {}; } ns.Scheduler = { + modes: MODE, + isEnabled: function () { return schedulerTriggers().length > 0; }, @@ -105,10 +153,12 @@ var SpotiSync = SpotiSync || {}; var timezone = ss ? ss.getSpreadsheetTimeZone() : Session.getScriptTimeZone(); var triggers = schedulerTriggers(); var documentStatus = ns.Storage.getDocumentStatus(); + var mode = triggers.length ? storedMode(triggers) : MODE.NONE; return { enabled: triggers.length > 0, + mode: mode, triggerCount: triggers.length, - schedule: scheduleLabel(timezone), + schedule: scheduleLabel(timezone, mode), timezone: timezone, lastCheckAt: documentStatus.SCHEDULER_LAST_CHECK_AT || '', lastCheckStatus: documentStatus.SCHEDULER_LAST_CHECK_STATUS || '', @@ -118,7 +168,7 @@ var SpotiSync = SpotiSync || {}; reconcile: reconcile, - // Compatibility helpers for old installed callbacks. Normal v1.4 UX never + // Compatibility helpers for old installed callbacks. Normal v1.5 UX never // exposes manual scheduler controls. enable: function () { return reconcile(); @@ -126,16 +176,20 @@ var SpotiSync = SpotiSync || {}; disable: function () { deleteSchedulerTriggers(); + rememberMode(MODE.NONE); refreshSummaryBestEffort(); return true; }, runDue: function () { try { - var result = ns.SyncEngine.runDue(); + var mode = storedMode(schedulerTriggers()); + var result = ns.SyncEngine.runDue({ schedulerMode: mode }); recordSchedulerCheck(result.status || 'Success', null); checkForUpdatesBestEffort(); - refreshSummaryBestEffort(); + if (result.status !== 'No jobs due') { + refreshSummaryBestEffort(); + } return result; } catch (error) { recordSchedulerCheck('Error', error); @@ -147,6 +201,8 @@ var SpotiSync = SpotiSync || {}; _schedulerTriggers: schedulerTriggers, _scheduleLabel: scheduleLabel, + _requiredModeForJobs: requiredModeForJobs, + _storedMode: storedMode, _reconcile: reconcile }; })(SpotiSync); diff --git a/src/90_Ui.gs b/src/90_Ui.gs index 47d426e..c4c76ee 100644 --- a/src/90_Ui.gs +++ b/src/90_Ui.gs @@ -63,26 +63,26 @@ var SpotiSync = SpotiSync || {}; 'el("repair").onclick=function(){if(!confirm("Repair Spoti Sync data and reconcile automation? Your Spotify credentials and playlist IDs are preserved."))return;rpc("spotiSyncRepairApp",[],function(r){renderHome(r.home);toast(r.message,r.warning?"warn":"ok");});};' + 'el("disconnect").onclick=function(){if(!confirm("Disconnect Spotify? Your Client ID and jobs will be kept."))return;rpc("spotiSyncDisconnect",[],function(home){renderHome(home);toast("Spotify disconnected. Jobs were kept.","ok");});};}' + 'function openEditor(jobId){toast("Loading playlists…","");rpc("spotiSyncGetJobEditorModel",[jobId||""],function(m){EDITOR=m;SOURCE_SELECTED=m.config.sourcePlaylistId||"";TARGET_SELECTED=m.config.targetPlaylistId||"";renderEditor();toast("","");});}' + + 'function renderAutomation(){return "
"+(EDITOR.automationOptions||[]).map(function(o){var radio=" ";if(o.intervalUnit==="hours"){return "";}if(o.intervalUnit==="days"){return "";}return "";}).join("")+"
";}' + 'function renderEditor(){var c=EDITOR.config;var html=renderHeader()+"

"+(EDITOR.mode==="edit"?"Edit job":"Add job")+"

";if(EDITOR.catalogWarning){html+="
"+esc(EDITOR.catalogWarning)+"
";}' + 'html+="
";' + 'html+="
";' + - 'html+="
";' + - 'html+="
";' + + 'html+="
"+renderAutomation();' + 'html+="
Adds the latest successful sync time and sid.is-a.dev to the target playlist description.
";' + 'html+="
Advanced
";' + 'html+="
"+(EDITOR.mode==="edit"?"":"")+"
";' + 'el("root").innerHTML=html;el("back").onclick=function(){renderHome(STATE);};' + 'el("sourceType").value=c.sourceType;el("behavior").innerHTML=EDITOR.behaviorOptions.map(function(x){return "";}).join("");el("behavior").value=c.behavior;' + - 'el("jobName").value=c.name||"";el("heartbeat").checked=c.heartbeatEnabled!==false;el("intervalDays").value=c.intervalDays||1;' + + 'el("jobName").value=c.name||"";el("heartbeat").checked=c.heartbeatEnabled!==false;el("intervalHours").value=c.intervalHours||1;el("intervalDays").value=c.intervalDays||1;' + 'Array.from(document.querySelectorAll("input[name=automation]")).forEach(function(x){x.checked=x.value===c.automation;x.onchange=toggleAutomation;});' + 'el("sourceType").onchange=toggleEditor;el("targetMode").onchange=toggleEditor;el("sourceSearch").oninput=function(){renderPlaylist("source");};el("targetSearch").oninput=function(){renderPlaylist("target");};' + 'el("sourcePlaylist").onchange=function(){SOURCE_SELECTED=this.value;};el("targetPlaylist").onchange=function(){TARGET_SELECTED=this.value;};' + 'el("save").onclick=saveJob;el("refreshCatalog").onclick=refreshCatalog;if(el("delete"))el("delete").onclick=deleteJob;renderPlaylist("source");renderPlaylist("target");toggleEditor();toggleAutomation();}' + 'function toggleEditor(){el("sourcePlaylistBox").className=el("sourceType").value==="PLAYLIST"?"":"hidden";var create=el("targetMode").value==="create";el("targetExisting").className=create?"hidden":"";el("targetCreate").className=create?"":"hidden";}' + - 'function currentAutomation(){var x=document.querySelector("input[name=automation]:checked");return x?x.value:"DAILY";}function toggleAutomation(){el("intervalDays").disabled=currentAutomation()!=="INTERVAL";}' + + 'function currentAutomation(){var x=document.querySelector("input[name=automation]:checked");return x?x.value:"DAILY";}function toggleAutomation(){var mode=currentAutomation();el("intervalHours").disabled=mode!=="HOURS";el("intervalDays").disabled=mode!=="DAYS";}' + 'function playlistLabel(p){var bits=[p.name];if(p.itemCount)bits.push(p.itemCount+" tracks");if(p.owner)bits.push(p.owner);return bits.join(" · ");}' + 'function renderPlaylist(which){var search=el(which+"Search").value.toLowerCase();var select=el(which+"Playlist");var selected=which==="source"?SOURCE_SELECTED:TARGET_SELECTED;select.innerHTML="";(EDITOR.catalog||[]).filter(function(p){return !search||p.name.toLowerCase().indexOf(search)!==-1||String(p.owner||"").toLowerCase().indexOf(search)!==-1;}).forEach(function(p){var o=document.createElement("option");o.value=p.id;o.textContent=playlistLabel(p);if(p.id===selected)o.selected=true;select.appendChild(o);});}' + - 'function saveJob(){var button=el("save");button.disabled=true;var payload={jobId:EDITOR.config.jobId||"",name:el("jobName").value,sourceType:el("sourceType").value,sourcePlaylistId:SOURCE_SELECTED,sourceManual:el("sourceManual").value,targetMode:el("targetMode").value,targetPlaylistId:TARGET_SELECTED,targetManual:el("targetManual").value,newTargetName:el("newTargetName").value,targetPublic:el("targetPublic").checked,behavior:el("behavior").value,automation:currentAutomation(),intervalDays:el("intervalDays").value,heartbeatEnabled:el("heartbeat").checked};toast("Saving…","");rpc("spotiSyncSaveJob",[payload],function(r){renderHome(r.home);toast(r.message,r.warning?"warn":"ok");});}' + + 'function saveJob(){var button=el("save");button.disabled=true;var payload={jobId:EDITOR.config.jobId||"",name:el("jobName").value,sourceType:el("sourceType").value,sourcePlaylistId:SOURCE_SELECTED,sourceManual:el("sourceManual").value,targetMode:el("targetMode").value,targetPlaylistId:TARGET_SELECTED,targetManual:el("targetManual").value,newTargetName:el("newTargetName").value,targetPublic:el("targetPublic").checked,behavior:el("behavior").value,automation:currentAutomation(),existingFrequency:EDITOR.config.frequency||"Daily",intervalHours:el("intervalHours").value,intervalDays:el("intervalDays").value,heartbeatEnabled:el("heartbeat").checked};toast("Saving…","");rpc("spotiSyncSaveJob",[payload],function(r){renderHome(r.home);toast(r.message,r.warning?"warn":"ok");});}' + 'function refreshCatalog(){toast("Refreshing playlists…","");rpc("spotiSyncRefreshJobCatalog",[],function(list){EDITOR.catalog=list||[];renderPlaylist("source");renderPlaylist("target");toast("Playlist list refreshed.","ok");});}' + 'function deleteJob(){if(!confirm("Delete this Spoti Sync job? This does not delete either Spotify playlist."))return;rpc("spotiSyncDeleteJob",[EDITOR.config.jobId],function(r){renderHome(r.home);toast(r.message,r.warning?"warn":"ok");});}' + 'function runJob(job){if(job.behavior==="Exact Mirror"&&!confirm("Run "+job.name+" now? Exact Mirror may remove tracks from the target so it matches the source."))return;toast("Syncing "+job.name+"…","");rpc("spotiSyncRunJob",[job.jobId],function(r){renderHome(r.home);var j=r.result&&r.result.jobs&&r.result.jobs[0];if(j&&j.status==="Error"){toast(j.error||"Sync failed.","bad");}else if(j){toast("Synced: +"+j.added+" / -"+j.removed+(j.warning?" · "+j.warning:""),j.warning?"warn":"ok");}else{toast("Sync complete.","ok");}});}' + @@ -125,4 +125,4 @@ var SpotiSync = SpotiSync || {}; _appHtml: appHtml }; -})(SpotiSync); +})(SpotiSync); \ No newline at end of file diff --git a/src/92_JobEditor.gs b/src/92_JobEditor.gs index b92074d..ebf06c4 100644 --- a/src/92_JobEditor.gs +++ b/src/92_JobEditor.gs @@ -8,8 +8,10 @@ var SpotiSync = SpotiSync || {}; var NAME_PROPERTY_PREFIX = 'PLAYLIST_NAME_'; var AUTOMATION = Object.freeze({ OFF: 'OFF', + HOURLY: 'HOURLY', + HOURS: 'HOURS', DAILY: 'DAILY', - INTERVAL: 'INTERVAL' + DAYS: 'DAYS' }); function normalizePlaylist(playlist) { @@ -142,10 +144,17 @@ var SpotiSync = SpotiSync || {}; } function automationForJob(job) { + var unit = job.frequencyUnit || ns.Constants.FREQUENCY_UNITS.DAY; + var interval = Number(job.frequencyInterval || + (unit === ns.Constants.FREQUENCY_UNITS.HOUR ? job.intervalHours : job.intervalDays) || 1); + if (!job.enabled) { return AUTOMATION.OFF; } - return job.intervalDays === 1 ? AUTOMATION.DAILY : AUTOMATION.INTERVAL; + if (unit === ns.Constants.FREQUENCY_UNITS.HOUR) { + return interval === 1 ? AUTOMATION.HOURLY : AUTOMATION.HOURS; + } + return interval === 1 ? AUTOMATION.DAILY : AUTOMATION.DAYS; } function automationLabel(job) { @@ -172,7 +181,10 @@ var SpotiSync = SpotiSync || {}; behavior: ns.SheetStore._behaviorLabel(job.strategy), automation: automationLabel(job), automated: job.enabled, + frequencyUnit: job.frequencyUnit, + frequencyInterval: job.frequencyInterval, intervalDays: job.intervalDays, + intervalHours: job.intervalHours, heartbeatEnabled: job.heartbeatEnabled !== false, lastSuccess: job.lastSuccess ? ns.SheetStore._formatTimestamp(job.lastSuccess) : 'Never', lastStatus: job.lastStatus || '', @@ -198,6 +210,7 @@ var SpotiSync = SpotiSync || {}; clientIdHint: clientId ? ('Configured: …' + clientId.slice(-6)) : '', automation: { enabled: scheduler.enabled, + mode: scheduler.mode || '', triggerCount: scheduler.triggerCount, automatedJobs: automatedJobs, lastCheckAt: scheduler.lastCheckAt || '', @@ -245,7 +258,9 @@ var SpotiSync = SpotiSync || {}; targetName: '', behavior: ns.SheetStore._behaviorLabel(ns.Constants.STRATEGIES.MIRROR), automation: AUTOMATION.DAILY, + frequency: 'Daily', intervalDays: 1, + intervalHours: 1, heartbeatEnabled: true }; } @@ -260,7 +275,9 @@ var SpotiSync = SpotiSync || {}; targetName: targetName(job), behavior: ns.SheetStore._behaviorLabel(job.strategy), automation: automationForJob(job), - intervalDays: job.intervalDays, + frequency: job.frequencyLabel || ns.SheetStore.getAutomationLabel(job), + intervalDays: job.intervalDays || 1, + intervalHours: job.intervalHours || 1, heartbeatEnabled: job.heartbeatEnabled !== false }; } @@ -291,30 +308,45 @@ var SpotiSync = SpotiSync || {}; frequencyLimits: ns.SheetStore.frequencyLimits(), automationOptions: [ { value: AUTOMATION.OFF, label: 'Off' }, + { value: AUTOMATION.HOURLY, label: 'Hourly' }, + { value: AUTOMATION.HOURS, label: 'Every N hours', intervalUnit: 'hours' }, { value: AUTOMATION.DAILY, label: 'Daily' }, - { value: AUTOMATION.INTERVAL, label: 'Every N days' } + { value: AUTOMATION.DAYS, label: 'Every N days', intervalUnit: 'days' } ] }; } - function intervalForPayload(data) { + function frequencyForPayload(data) { var mode = ns.Core.trim(data.automation).toUpperCase(); - var requested; + var existing; if (mode === AUTOMATION.OFF) { - requested = Number(data.intervalDays || 1); - if (!Number.isInteger(requested) || requested < 1) { - requested = 1; + existing = ns.Core.trim(data.existingFrequency) || 'Daily'; + try { + return ns.SheetStore.parseFrequency(existing); + } catch (ignored) { + return ns.SheetStore.parseFrequency('Daily'); } - return ns.SheetStore._parseFrequency(ns.SheetStore._frequencyLabel(requested)); + } + if (mode === AUTOMATION.HOURLY) { + return ns.SheetStore.parseFrequency('Hourly'); + } + if (mode === AUTOMATION.HOURS) { + return ns.SheetStore.parseFrequency( + ns.SheetStore.formatFrequency(Number(data.intervalHours), ns.Constants.FREQUENCY_UNITS.HOUR) + ); } if (mode === AUTOMATION.DAILY) { - return 1; + return ns.SheetStore.parseFrequency('Daily'); } - if (mode === AUTOMATION.INTERVAL) { - return ns.SheetStore._parseFrequency(ns.SheetStore._frequencyLabel(Number(data.intervalDays))); + if (mode === AUTOMATION.DAYS || mode === 'INTERVAL') { + // INTERVAL is accepted only as a compatibility value for a stale v1.4 + // sidebar that was already open during an upgrade. + return ns.SheetStore.parseFrequency( + ns.SheetStore.formatFrequency(Number(data.intervalDays), ns.Constants.FREQUENCY_UNITS.DAY) + ); } - throw new Error('Choose Off, Daily, or Every N days for Automation.'); + throw new Error('Choose Off, Hourly, Every N hours, Daily, or Every N days for Automation.'); } function save(payload) { @@ -325,7 +357,7 @@ var SpotiSync = SpotiSync || {}; var sourcePlaylist = null; var targetPlaylist; var strategy; - var intervalDays; + var frequency; var enabled; var sourceLabel; var targetLabel; @@ -338,7 +370,7 @@ var SpotiSync = SpotiSync || {}; 'Choose Liked Songs or a Spotify playlist as the source.' ); strategy = ns.SheetStore._parseBehaviorLabel(data.behavior); - intervalDays = intervalForPayload(data); + frequency = frequencyForPayload(data); enabled = automation !== AUTOMATION.OFF; try { catalog = getCatalog(false); @@ -379,7 +411,7 @@ var SpotiSync = SpotiSync || {}; targetPlaylistId: targetPlaylist.id, targetLabel: targetLabel, strategy: strategy, - intervalDays: intervalDays, + frequency: frequency.label, heartbeatEnabled: data.heartbeatEnabled !== false }); @@ -465,7 +497,7 @@ var SpotiSync = SpotiSync || {}; _cleanStoredLabel: cleanStoredLabel, _findPlaylistById: findPlaylistById, _automationForJob: automationForJob, - _intervalForPayload: intervalForPayload, + _frequencyForPayload: frequencyForPayload, _editorConfig: editorConfig }; -})(SpotiSync); +})(SpotiSync); \ No newline at end of file