Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,10 @@ A description-only failure is non-fatal and produces `Success with warning`. A f
`75_PlaylistHeartbeat.gs` owns description formatting and phrase rotation:

```text
[rotating Spoti Sync phrase] · sid.is-a.dev · Synced [weekday] at [time]
[rotating Spoti Sync phrase] · sid.is-a.dev · 🔄 Thu, Aug 20 · 3:16 AM
```

The job-owned `Heartbeat Enabled` field controls whether SyncEngine invokes that module. Disabling it does not clear or replace the current Spotify description.
The timestamp uses the spreadsheet timezone. The job-owned `Heartbeat Enabled` field controls whether SyncEngine invokes that module. Disabling it does not clear or replace the current Spotify description.

Phrase rotation state remains in Document Properties and advances only after Spotify accepts the description update.

Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to Spoti Sync are documented here.

## 1.5.1 — 2026-08-20

### Changed

- Updated playlist heartbeat timestamps to the compact format `🔄 Thu, Aug 20 · 3:16 AM` while keeping the existing rotating Spoti Sync phrase and `sid.is-a.dev` signature.
- Preserved spreadsheet-timezone formatting, heartbeat opt-in/out behavior, phrase rotation, playlist-write ordering, and `Success with warning` semantics for description-only failures.

### Upgrade note

- Install the 1.5.1 bundle in the same Apps Script project, save, and reload the Sheet. No Spotify reconnection, job recreation, scheduler recreation, repair step, or playlist-ID re-entry is required.

## 1.5.0 — 2026-08-20

### Added
Expand Down
8 changes: 4 additions & 4 deletions docs/version.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"schema": 1,
"version": "1.5.0",
"version": "1.5.1",
"channel": "stable",
"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": [
"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."
"Polishes playlist heartbeat timestamps to the compact format 🔄 Thu, Aug 20 · 3:16 AM.",
"Preserves rotating heartbeat phrases, the sid.is-a.dev signature, spreadsheet-timezone formatting, and Success-with-warning behavior.",
"Presentation-only patch: scheduler, OAuth, jobs, playlist IDs, sync behavior, and Spotify API call count are unchanged."
]
}
24 changes: 16 additions & 8 deletions scripts/test-heartbeat.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..');
const properties = new Map();
const calls = [];
const heartbeatDate = new Date('2026-08-19T21:46:00Z');
const context = vm.createContext({
console, Date, Object, Array, String, Number, Boolean, Math, JSON, RegExp, Error,
encodeURIComponent,
Utilities: {
formatDate(date, timezone, pattern) {
assert.strictEqual(timezone, 'Asia/Kolkata');
assert.strictEqual(pattern, "EEEE 'at' h:mm a");
return 'Saturday at 2:22 AM';
assert.strictEqual(pattern, 'EEE, MMM d · h:mm a');
assert.strictEqual(date.toISOString(), heartbeatDate.toISOString());
return 'Thu, Aug 20 · 3:16 AM';
}
},
LockService: {
Expand Down Expand Up @@ -45,6 +47,9 @@ context.SpotiSync.SheetStore = {
context.SpotiSync.SpotifyApi = {
updatePlaylistDescription(playlistId, description) {
calls.push(['description', playlistId, description]);
},
updatePlaylistName() {
calls.push(['name']);
}
};
load('75_PlaylistHeartbeat.gs');
Expand All @@ -53,25 +58,28 @@ const job = { jobId: 'job_abc', targetPlaylist: '1234567890AB' };
const H = context.SpotiSync.PlaylistHeartbeat;

(function testDescriptionTemplate() {
const text = H._buildDescription(job, new Date('2026-08-15T00:00:00Z'), 'Asia/Kolkata', 0);
assert.strictEqual(text, 'Kept fresh with Spoti Sync ✨ · sid.is-a.dev · Synced Saturday at 2:22 AM');
const text = H._buildDescription(job, heartbeatDate, 'Asia/Kolkata', 0);
assert.strictEqual(text, 'Kept fresh with Spoti Sync ✨ · sid.is-a.dev · 🔄 Thu, Aug 20 · 3:16 AM');
})();

(function testPhraseRotationOnlyAfterSuccessfulUpdate() {
const first = H.update(job, new Date('2026-08-15T00:00:00Z'));
const second = H.update(job, new Date('2026-08-15T00:00:00Z'));
calls.length = 0;
const first = H.update(job, heartbeatDate);
const second = H.update(job, heartbeatDate);
assert.strictEqual(first.ok, true);
assert.strictEqual(first.phraseIndex, 0);
assert.strictEqual(second.phraseIndex, 1);
assert(second.description.startsWith('Kept in sync with Spoti Sync 🔄'));
assert.strictEqual(calls.filter((entry) => entry[0] === 'description').length, 2);
assert.strictEqual(calls.some((entry) => entry[0] === 'name'), false, 'Heartbeat must never rename the playlist.');
})();

(function testFailureIsReturnedWithoutAdvancingRotation() {
const currentIndex = H._readIndex(job);
context.SpotiSync.SpotifyApi.updatePlaylistDescription = function () {
throw new Error('temporary description failure');
};
const result = H.update(job, new Date('2026-08-15T00:00:00Z'));
const result = H.update(job, heartbeatDate);
assert.strictEqual(result.ok, false);
assert.match(result.error, /temporary description failure/);
assert.strictEqual(H._readIndex(job), currentIndex);
Expand Down Expand Up @@ -144,4 +152,4 @@ load('70_SyncEngine.gs');
assert.match(summary.warning, /description API unavailable/);
})();

console.log('Playlist heartbeat opt-in/out and ordering tests passed.');
console.log('Playlist heartbeat timestamp, opt-in/out, and ordering tests passed.');
14 changes: 7 additions & 7 deletions scripts/test-update-checker.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ let fetchCount = 0;
let responseStatus = 200;
let responseBody = {
schema: 1,
version: '1.5.1',
version: '1.5.2',
channel: 'stable',
released_at: '2026-09-01',
installer_url: 'https://example.com/update',
Expand Down Expand Up @@ -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.5.1');
assert.strictEqual(status.latestVersion, '1.5.2');
assert.strictEqual(status.updateAvailable, true);
assert.strictEqual(status.checkStatus, 'Update available');
assert.strictEqual(status.installerUrl, 'https://example.com/update');
Expand All @@ -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.5.1');
assert.strictEqual(status.latestVersion, '1.5.2');
})();

(function testCachedAvailabilityRecomputesAfterCodeUpgrade() {
const installedVersion = context.SpotiSync.VERSION;
context.SpotiSync.VERSION = '1.5.1';
context.SpotiSync.VERSION = '1.5.2';
const status = UpdateChecker.getCachedStatus();
assert.strictEqual(status.currentVersion, '1.5.1');
assert.strictEqual(status.latestVersion, '1.5.1');
assert.strictEqual(status.currentVersion, '1.5.2');
assert.strictEqual(status.latestVersion, '1.5.2');
assert.strictEqual(status.updateAvailable, false);
assert.strictEqual(status.checkStatus, 'Up to date');
context.SpotiSync.VERSION = installedVersion;
Expand Down Expand Up @@ -126,7 +126,7 @@ const { UpdateChecker, VERSION } = context.SpotiSync;
responseStatus = 200;
responseBody = {
...responseBody,
version: '1.5.1'
version: '1.5.2'
};
documentStatus.UPDATE_LAST_CHECK_AT = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
const status = UpdateChecker.check({ force: false });
Expand Down
2 changes: 1 addition & 1 deletion src/00_Core.gs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ var SpotiSync = SpotiSync || {};
(function (ns) {
'use strict';

ns.VERSION = '1.5.0';
ns.VERSION = '1.5.1';

ns.Constants = Object.freeze({
APP_NAME: 'Spoti Sync',
Expand Down
4 changes: 2 additions & 2 deletions src/75_PlaylistHeartbeat.gs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ var SpotiSync = SpotiSync || {};
throw new Error('Cannot build playlist heartbeat with an invalid timestamp.');
}

timestamp = Utilities.formatDate(when, timezone, "EEEE 'at' h:mm a");
return PHRASES[index] + ' · ' + ns.Constants.HEARTBEAT_SIGNATURE + ' · Synced ' + timestamp;
timestamp = Utilities.formatDate(when, timezone, 'EEE, MMM d · h:mm a');
return PHRASES[index] + ' · ' + ns.Constants.HEARTBEAT_SIGNATURE + ' · 🔄 ' + timestamp;
}

ns.PlaylistHeartbeat = {
Expand Down