-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathupdate-service-pages.js
More file actions
394 lines (316 loc) · 15.8 KB
/
update-service-pages.js
File metadata and controls
394 lines (316 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
// update-service-pages.js
// - Fetches / updates teletext services pages from remote repositories to a local location (as defined in config.js)
// How to auto run this...
// pm2 start update-service-pages.js --cron "*/5 * * * *" --no-autorestart
// by Danny Allen (me@dannya.com)
//
// When problem solving, remove the pm2 process and run this manually and see if it crashes.
// $ pm2 delete update-service-pages.js
// $ node update-service-pages.js -v
'use strict'
const fs = require('fs')
const path = require('path')
const util = require('util')
const exec = util.promisify(require('child_process').exec)
const commandLineArgs = require('command-line-args')
const commandLineUsage = require('command-line-usage')
const colorette = require('colorette')
const svn = require('@taiyosen/easy-svn')
const simpleGit = require('simple-git')
const { deletedDiff } = require('deep-object-diff')
const xxhash = require('@pacote/xxhash')
// import package.json so we can get the current version
const PACKAGE_JSON = JSON.parse(fs.readFileSync('./package.json'))
// import constants and config for use server-side
const CONST = require('./constants.js')
const CONFIG = require('./config.js')
// ── Constants ────────────────────────────────────────────────────────────────
const PAGE_FILE_EXT = '.tti'
const FILE_ENCODING_INPUT = CONST.ENCODING_ASCII
const FILE_ENCODING_OUTPUT = CONST.ENCODING_ASCII
const FILE_CHAR_REPLACEMENTS = {
'\x8d': '\x1bM'
}
const DESCRIPTION_NULLIFY = ['Description goes here']
// SVN flags used on every svn command so self-signed / expired certs don't block us
const SVN_TRUST_ARGS = [
'--non-interactive',
'--trust-server-cert-failures=cn-mismatch,unknown-ca,expired'
]
// ── CLI options ──────────────────────────────────────────────────────────────
const availableOptions = [
{ name: 'help', description: 'Print this usage guide.', alias: 'h', type: Boolean },
{ name: 'silent', description: 'No log messages output to the console.', alias: 's', type: Boolean },
{ name: 'verbose', description: 'Verbose log messages output to the console.', alias: 'v', type: Boolean },
{ name: 'force', description: 'Force an update of all services, regardless of last update time', alias: 'f', type: Boolean }
]
const options = commandLineArgs(availableOptions)
if (options.help) {
console.log(commandLineUsage([
{
header: 'update-service-pages.js',
content: 'Fetches / updates teletext services pages from remote repositories to a local location (as defined in config.js)'
},
{ header: 'Options', optionList: availableOptions }
]))
process.exit(0)
}
// ── Hasher ───────────────────────────────────────────────────────────────────
const hasher = xxhash.xxh64(2654435761)
// ── Helpers ──────────────────────────────────────────────────────────────────
function log (msg) { if (!options.silent) console.log(msg) }
function logVerbose (msg) { if (options.verbose) console.log(msg) }
function logError (msg) { if (!options.silent) console.error(msg) }
/** Copy files with --update (only overwrites if src is newer).
* Returns true if any file was actually copied. */
async function cpUpdate (src, dest) {
const { stdout, stderr } = await exec(`cp --update -v ${src} ${dest}`)
if (stderr) logError(`cp stderr: ${stderr}`)
logVerbose(`cp stdout: ${stdout}`)
return stdout.length > 0
}
/** Return a git client rooted at the given directory. */
function gitAt (dir) {
return simpleGit(dir)
}
// ── readBackServices ─────────────────────────────────────────────────────────
/** For editable services, copy on-air pages back to the repo and push.
* Returns a Set of serviceIds that had changes (these must not be
* overwritten by the subsequent updateServices pass). */
async function readBackServices () {
const changed = new Set()
for (const serviceId in CONFIG[CONST.CONFIG.SERVICES_AVAILABLE]) {
const serviceData = CONFIG[CONST.CONFIG.SERVICES_AVAILABLE][serviceId]
if (!serviceData.isEditable) continue
log(`[readBackServices] editable service id = ${serviceId}`)
const serviceRepoDir = path.join(CONFIG[CONST.CONFIG.SERVICE_PAGES_DIR], serviceId)
const serviceOnairDir = path.join(CONFIG[CONST.CONFIG.SERVICE_PAGES_SERVE_DIR], serviceId)
log(`from: ${serviceOnairDir} to: ${serviceRepoDir}`)
try {
// Copy updated .tti files from on-air dir back into the repo dir
const anyCopied = await cpUpdate(`${serviceOnairDir}/*.tti`, `${serviceRepoDir}/`)
if (anyCopied) {
log('[readBackServices] one or more page files changed')
changed.add(serviceId)
}
} catch (err) {
// cp exits non-zero when the glob matches nothing — treat that as "no changes"
logVerbose(`[readBackServices] cp skipped for ${serviceId}: ${err.message}`)
}
// Only Git repos are editable; stage, commit and push any changes
logVerbose(`[readBackServices] pushing ${serviceId} → ${serviceData.updateUrl}`)
try {
await gitAt(serviceRepoDir)
.add('*.tti')
.commit('Muttlee auto commit v1', ['-a', '--allow-empty'])
.push()
} catch (err) {
logError(`[readBackServices] git push failed for ${serviceId}: ${err.message}`)
// Don't rethrow — a push failure shouldn't abort the whole run
}
}
return changed
}
// ── updateServices ───────────────────────────────────────────────────────────
async function updateServices (changed) {
for (const serviceId in CONFIG[CONST.CONFIG.SERVICES_AVAILABLE]) {
// If readBackServices detected local changes, skip pulling this service
// so we don't overwrite edits that haven't been pushed yet
if (changed.has(serviceId)) {
logVerbose(`Skipping update of ${serviceId} (local changes detected)`)
continue
}
const serviceData = CONFIG[CONST.CONFIG.SERVICES_AVAILABLE][serviceId]
const serviceTargetDir = path.join(CONFIG[CONST.CONFIG.SERVICE_PAGES_DIR], serviceId)
const serviceServeDir = path.join(CONFIG[CONST.CONFIG.SERVICE_PAGES_SERVE_DIR], serviceId)
const serviceManifestFile = path.join(serviceServeDir, 'manifest.json')
// ── Read / initialise manifest ──────────────────────────────────────────
let serviceManifest = { id: serviceId }
if (fs.existsSync(serviceManifestFile)) {
try {
serviceManifest = JSON.parse(fs.readFileSync(serviceManifestFile, 'utf8'))
} catch (err) {
logError(`WARNING: Could not parse manifest for ${serviceId}, reinitialising. (${err.message})`)
serviceManifest = { id: serviceId }
}
}
if (!serviceManifest.pages) serviceManifest.pages = {}
// ── Decide whether to pull from remote ─────────────────────────────────
let shouldUpdate = false
if (serviceData.updateUrl) {
if (!serviceData.updateInterval || !serviceManifest.lastUpdated) {
shouldUpdate = true
} else {
const nextUpdateAt = Date.parse(serviceManifest.lastUpdated) + (serviceData.updateInterval * 60 * 1000)
shouldUpdate = nextUpdateAt < Date.now()
logVerbose(
`Service ${serviceId}: shouldUpdate=${shouldUpdate}, ` +
`mins to next update=${Math.round((nextUpdateAt - Date.now()) / 60000)}`
)
}
if (shouldUpdate || options.force) {
const svnClient = new svn.SVNClient()
svnClient.setConfig({ silent: !options.verbose })
if (!fs.existsSync(serviceTargetDir)) {
// ── First-time checkout ───────────────────────────────────────────
log(colorette.blueBright(
`First time checkout of '${serviceId}' service page files (to ${serviceTargetDir})...`
))
if (serviceData.repoType === 'svn') {
await svnClient.cmd('checkout', [serviceData.updateUrl, serviceTargetDir, ...SVN_TRUST_ARGS])
} else {
await gitAt('.').clone(serviceData.updateUrl, serviceTargetDir)
}
} else {
// ── Subsequent update ─────────────────────────────────────────────
log(`Updating '${serviceId}' service page files...`)
if (serviceData.repoType === 'svn') {
try {
await svnClient.cmd('update', [serviceTargetDir, ...SVN_TRUST_ARGS])
} catch (err) {
if (err.message.includes('E155017')) {
// Checksum mismatch — nuke and re-checkout
logError(`Checksum mismatch for ${serviceId}. Performing fresh checkout...`)
fs.rmSync(serviceTargetDir, { recursive: true, force: true })
await svnClient.cmd('checkout', [serviceData.updateUrl, serviceTargetDir, ...SVN_TRUST_ARGS])
} else {
logError(`SVN update failed for ${serviceId}: ${err.message}`)
continue // Skip this service rather than crashing the whole run
}
}
} else {
// Pull in the correct repo directory
await gitAt(serviceTargetDir).pull()
}
}
}
}
// ── Ensure serve directory exists ───────────────────────────────────────
if (!fs.existsSync(serviceServeDir)) {
logVerbose(`Creating ${serviceServeDir} output directory`)
fs.mkdirSync(serviceServeDir, { recursive: true })
}
// ── Scan and sync page files ────────────────────────────────────────────
log(`\nChecking '${serviceId}' for updates...`)
const recalculatedManifestPages = {}
let manifestChanged = false
let servicePageFiles
try {
servicePageFiles = fs.readdirSync(serviceTargetDir)
} catch (err) {
logError(`Could not read ${serviceTargetDir}: ${err.message}`)
continue
}
if (servicePageFiles.length <= 1) {
logError(`WARNING: '${serviceId}' appears to have no page files — skipping sync.`)
continue
}
for (const filename of servicePageFiles) {
if (!filename.endsWith(PAGE_FILE_EXT)) continue
const sourceFilePath = path.join(serviceTargetDir, filename)
let fileContent
try {
fileContent = fs.readFileSync(sourceFilePath, FILE_ENCODING_INPUT).toString()
} catch (err) {
logError(`Could not read ${sourceFilePath}: ${err.message}`)
continue
}
// Hash original content
hasher.reset()
const fileContentHash = hasher.update(fileContent).digest('hex')
// Apply character replacements
for (const [from, to] of Object.entries(FILE_CHAR_REPLACEMENTS)) {
fileContent = fileContent.replace(from, to)
}
// Hash post-replacement content
hasher.reset()
const fileContentUpdatedHash = hasher.update(fileContent).digest('hex')
// Extract metadata from file
let description = null
let pageNumber = null
for (const line of fileContent.split('\n')) {
if (line.startsWith('DE,')) {
const raw = line.slice(3).trim()
description = (raw && !DESCRIPTION_NULLIFY.includes(raw)) ? raw : null
}
if (line.startsWith('PN,')) {
pageNumber = line.slice(3, 6)
}
}
if (!pageNumber) {
log(`ERROR: Page number could not be extracted from ${sourceFilePath}`)
continue
}
if (recalculatedManifestPages[pageNumber]) {
logError(colorette.redBright(
`ERROR: p${pageNumber} already defined in ${recalculatedManifestPages[pageNumber].f}, ` +
`please fix this in ${filename} (change to an unused page number)`
))
continue
}
// If the file is unchanged, carry the existing manifest entry forward as-is
if (
serviceManifest.pages[pageNumber] &&
serviceManifest.pages[pageNumber].oh === fileContentHash
) {
recalculatedManifestPages[pageNumber] = serviceManifest.pages[pageNumber]
continue
}
// File is new or changed — rebuild its manifest entry
manifestChanged = true
const manifestPageEntry = { f: filename, p: pageNumber, oh: fileContentHash }
if (fileContentUpdatedHash !== fileContentHash) manifestPageEntry.nh = fileContentUpdatedHash
if (description) manifestPageEntry.d = description
recalculatedManifestPages[pageNumber] = manifestPageEntry
// Write updated file to serve directory
const targetFilePath = path.join(serviceServeDir, filename)
try {
fs.writeFileSync(targetFilePath, fileContent, FILE_ENCODING_OUTPUT)
logVerbose(`p${pageNumber} (${filename}) has changed, copied to live`)
} catch (err) {
logError(`Could not write ${targetFilePath}: ${err.message}`)
}
serviceManifest.lastModified = new Date()
}
// ── Remove pages deleted from the repo ──────────────────────────────────
const deletedPages = deletedDiff(serviceManifest.pages, recalculatedManifestPages)
if (Object.keys(deletedPages).length > 0) {
for (const pageNumber of Object.keys(deletedPages)) {
const filename = serviceManifest.pages[pageNumber]?.f
if (!filename) continue
try {
fs.unlinkSync(path.join(serviceServeDir, filename))
logVerbose(`Page removed from source, deleting p${pageNumber} (${filename})`)
} catch (err) {
logVerbose(`Could not delete ${filename}: ${err.message}`)
}
}
serviceManifest.lastModified = new Date()
manifestChanged = true
}
// ── Write manifest if anything changed ──────────────────────────────────
if (manifestChanged) {
serviceManifest.systemName = PACKAGE_JSON.name
serviceManifest.systemVersion = PACKAGE_JSON.version
serviceManifest.lastUpdated = new Date()
if (serviceData.updateInterval) serviceManifest.updateInterval = serviceData.updateInterval
serviceManifest.pages = recalculatedManifestPages
logVerbose(`Manifest updated — lastUpdated = ${serviceManifest.lastUpdated}`)
try {
fs.writeFileSync(serviceManifestFile, JSON.stringify(serviceManifest))
} catch (err) {
logError(`Could not write manifest for ${serviceId}: ${err.message}`)
}
}
}
log('updateServices completed')
}
// ── Entry point ──────────────────────────────────────────────────────────────
async function main () {
const changed = await readBackServices()
await updateServices(changed)
}
main().catch(err => {
console.error('Fatal error:', err)
process.exit(1)
})