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.
Source Liked Songs or Spotify playlist
Target Existing playlist or create new
Behavior Exact Mirror or Append Only
- Automation Off · Daily Every N days
+ Automation Off · 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 "Automation "+(EDITOR.automationOptions||[]).map(function(o){var radio=" ";if(o.intervalUnit==="hours"){return ""+radio+"Every hours ";}if(o.intervalUnit==="days"){return ""+radio+"Every days ";}return ""+radio+esc(o.label)+" ";}).join("")+"
";}' +
'function renderEditor(){var c=EDITOR.config;var html=renderHeader()+"← Back "+(EDITOR.mode==="edit"?"Edit job":"Add job")+" ";if(EDITOR.catalogWarning){html+=""+esc(EDITOR.catalogWarning)+"
";}' +
'html+="";' +
'html+="";' +
- 'html+="Behavior
";' +
- 'html+="Automation Off Daily Every days
";' +
+ 'html+="Behavior
"+renderAutomation();' +
'html+="";' +
'html+="Advanced Custom job name ";' +
'html+=""+(EDITOR.mode==="edit"?"Save changes":"Add job")+" Refresh playlists "+(EDITOR.mode==="edit"?"Delete job ":"")+"
";' +
'el("root").innerHTML=html;el("back").onclick=function(){renderHome(STATE);};' +
'el("sourceType").value=c.sourceType;el("behavior").innerHTML=EDITOR.behaviorOptions.map(function(x){return ""+esc(x)+" ";}).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