heyquilt 0.0.1 → 0.3.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// What's new and the update check: a bar across the top when a newer Quilt is out, and the
|
|
2
|
+
// release notes in a quilted pop-up (shown by itself the first time a new version runs, and
|
|
3
|
+
// again when a newer release appears). In the desktop app, "Update Quilt" installs it.
|
|
4
|
+
import { I, state, $, esc, api, remember, recall, toast } from './common.js'
|
|
5
|
+
import { quiltMark } from './mark.js'
|
|
6
|
+
|
|
7
|
+
const RECHECK_MS = 10 * 60 * 1000
|
|
8
|
+
let shownUnseen = false
|
|
9
|
+
let hiddenBar = false
|
|
10
|
+
|
|
11
|
+
/** Asks the app about versions, shows the update bar, and opens the notes after an update or a new release. */
|
|
12
|
+
export async function checkRelease () {
|
|
13
|
+
try { state.release = await api('GET', '/api/version') } catch { return }
|
|
14
|
+
const r = state.release
|
|
15
|
+
// Each newer release is announced once per computer: when it is cut while the app is open, or at the next launch.
|
|
16
|
+
// Not one you said not to show again.
|
|
17
|
+
if (r.outOfDate && !r.barHidden && recall('announced-release') !== r.latest.version) {
|
|
18
|
+
remember('announced-release', r.latest.version)
|
|
19
|
+
hiddenBar = false
|
|
20
|
+
openReleaseNotes()
|
|
21
|
+
} else if (r.unseen && !shownUnseen) {
|
|
22
|
+
openReleaseNotes()
|
|
23
|
+
}
|
|
24
|
+
if (r.unseen && !shownUnseen) {
|
|
25
|
+
shownUnseen = true
|
|
26
|
+
api('POST', '/api/version/seen').catch(() => {})
|
|
27
|
+
r.unseen = false
|
|
28
|
+
}
|
|
29
|
+
renderUpdateBar()
|
|
30
|
+
if (!checkRelease.timer) checkRelease.timer = setInterval(() => checkRelease().catch(() => {}), RECHECK_MS)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ------------------------------------------------------------- update --
|
|
34
|
+
const desktop = () => window.quiltDesktop?.installUpdate ? window.quiltDesktop : null
|
|
35
|
+
let update = null // { phase: 'downloading' | 'installing' | 'restarting' | 'failed', received, total, error }
|
|
36
|
+
|
|
37
|
+
/** Downloads and installs the newest Quilt from inside the desktop app. */
|
|
38
|
+
async function startUpdate () {
|
|
39
|
+
const d = desktop()
|
|
40
|
+
if (!d || (update && update.phase !== 'failed')) return
|
|
41
|
+
if (!startUpdate.listening) { startUpdate.listening = true; d.onUpdateProgress?.((p) => { update = { ...update, ...p }; refreshUpdateControls() }) }
|
|
42
|
+
update = { phase: 'downloading', received: 0, total: 0 }
|
|
43
|
+
refreshUpdateControls()
|
|
44
|
+
try {
|
|
45
|
+
await d.installUpdate()
|
|
46
|
+
update = { phase: 'restarting' }
|
|
47
|
+
} catch (err) {
|
|
48
|
+
update = { phase: 'failed', error: String(err?.message || err).replace(/^Error invoking remote method '[^']*': (Error: )?/, '') }
|
|
49
|
+
}
|
|
50
|
+
refreshUpdateControls()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The button that gets the newer Quilt: installs it in the desktop app, downloads it elsewhere. */
|
|
54
|
+
export function updateControl () {
|
|
55
|
+
const r = state.release
|
|
56
|
+
const download = `<a class="btn sm primary" href="${esc(r.downloadUrl)}" target="_blank" rel="noopener">${I.down}<span>Download</span></a>`
|
|
57
|
+
if (!desktop()) return `<span class="update-ctl">${download}</span>`
|
|
58
|
+
const phase = update?.phase
|
|
59
|
+
if (!phase) return `<span class="update-ctl"><button class="btn sm primary" type="button" data-update>${I.down}<span>Update Quilt</span></button></span>`
|
|
60
|
+
if (phase === 'failed') {
|
|
61
|
+
return `<span class="update-ctl update-failed"><span class="update-err" title="${esc(update.error)}">${esc(update.error)}</span><button class="btn sm primary" type="button" data-update>${I.refresh}<span>Try again</span></button>${download}</span>`
|
|
62
|
+
}
|
|
63
|
+
const pct = phase === 'downloading' && update.total ? ` ${Math.min(99, Math.floor(update.received / update.total * 100))}%` : ''
|
|
64
|
+
const label = phase === 'downloading' ? `Downloading…${pct}` : phase === 'installing' ? 'Installing…' : 'Restarting…'
|
|
65
|
+
return `<span class="update-ctl"><button class="btn sm primary update-busy" type="button" disabled>${quiltMark({ word: false, loop: true, cls: 'qm-inline' })}<span>${esc(label)}</span></button></span>`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Redraws every update button (the bar's and the pop-up's) in place. */
|
|
69
|
+
function refreshUpdateControls () {
|
|
70
|
+
if (!state.release) return
|
|
71
|
+
for (const el of document.querySelectorAll('.update-ctl')) el.outerHTML = updateControl()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The bar at the top of the window: this version is out of date. */
|
|
75
|
+
export function renderUpdateBar () {
|
|
76
|
+
const r = state.release
|
|
77
|
+
let bar = $('#update-bar')
|
|
78
|
+
const show = !!r?.outOfDate && !hiddenBar && !r.barHidden
|
|
79
|
+
document.body.classList.toggle('has-update', show)
|
|
80
|
+
if (!show) { bar?.remove(); return }
|
|
81
|
+
if (!bar) {
|
|
82
|
+
bar = document.createElement('div')
|
|
83
|
+
bar.id = 'update-bar'
|
|
84
|
+
bar.setAttribute('role', 'status')
|
|
85
|
+
document.body.prepend(bar)
|
|
86
|
+
}
|
|
87
|
+
bar.innerHTML = `
|
|
88
|
+
<span class="ub-dot"></span>
|
|
89
|
+
<span class="ub-text"><b>Quilt ${esc(r.latest.version)} is out.</b> You have ${esc(r.version)}.</span>
|
|
90
|
+
<button class="btn sm ghost" type="button" data-release-notes>What's new</button>
|
|
91
|
+
${updateControl()}
|
|
92
|
+
<label class="ub-never" title="Hide this bar until a newer Quilt than ${esc(r.latest.version)} is out"><input type="checkbox" data-update-never><span class="ub-long">Don't show this again</span><span class="ub-short">Don't show again</span></label>
|
|
93
|
+
<button class="btn sm ghost icon ub-x" type="button" data-hide-update aria-label="Hide until next time" title="Hide until Quilt starts again">${I.x}</button>`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Bold and code only; everything else is text. */
|
|
97
|
+
export function inline (md) {
|
|
98
|
+
return esc(md)
|
|
99
|
+
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
|
|
100
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const PATCHES = ['a', 'b', 'c', 'd']
|
|
104
|
+
const when = (d) => {
|
|
105
|
+
if (!d) return ''
|
|
106
|
+
const t = new Date(`${d}T12:00:00Z`)
|
|
107
|
+
return isNaN(t) ? d : t.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function releaseHtml (rel, i, { current = false, latest = false } = {}) {
|
|
111
|
+
return `
|
|
112
|
+
<section class="rn-rel${latest ? ' rn-latest' : ''}" aria-labelledby="rn-v-${esc(rel.version)}">
|
|
113
|
+
<header class="rn-rel-head">
|
|
114
|
+
<span class="rn-patch rn-patch-${PATCHES[i % PATCHES.length]}" id="rn-v-${esc(rel.version)}">${esc(rel.version)}</span>
|
|
115
|
+
<span class="rn-when">${esc(when(rel.date))}</span>
|
|
116
|
+
${current ? '<span class="rn-tag">You have this</span>' : ''}
|
|
117
|
+
${latest ? updateControl() : ''}
|
|
118
|
+
</header>
|
|
119
|
+
${rel.summary ? `<p class="rn-sum">${inline(rel.summary)}</p>` : ''}
|
|
120
|
+
${rel.items.length ? `<ul class="rn-list">${rel.items.map((it) => `<li>${inline(it)}</li>`).join('')}</ul>` : ''}
|
|
121
|
+
</section>`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The release notes pop-up: the logo, then every version newest first (and a newer one from GitHub, if any). */
|
|
125
|
+
export function openReleaseNotes () {
|
|
126
|
+
const r = state.release
|
|
127
|
+
if (!r) return
|
|
128
|
+
$('#rn-back')?.remove()
|
|
129
|
+
const back = document.createElement('div')
|
|
130
|
+
back.id = 'rn-back'
|
|
131
|
+
back.className = 'modal-back rn-back'
|
|
132
|
+
const newer = r.outOfDate ? [{ ...r.latest, items: r.latest.items.length ? r.latest.items : ['**A newer Quilt is ready.** Download it to see what changed.'] }] : []
|
|
133
|
+
const rels = r.releases || []
|
|
134
|
+
back.innerHTML = `
|
|
135
|
+
<div class="rn quilt-patch" role="dialog" aria-modal="true" aria-labelledby="rn-title">
|
|
136
|
+
<span class="quilt-stitch" aria-hidden="true"></span>
|
|
137
|
+
<div class="rn-card">
|
|
138
|
+
<header class="rn-head">
|
|
139
|
+
<div class="rn-logo">${quiltMark({ sew: true })}</div>
|
|
140
|
+
<h2 id="rn-title">What's new</h2>
|
|
141
|
+
<p class="rn-lead">${r.outOfDate ? `Quilt <b>${esc(r.latest.version)}</b> is out; you have ${esc(r.version)}.${desktop() ? ' Update takes a minute and restarts Quilt.' : ''}` : `You have the newest Quilt, <b>${esc(r.version)}</b>.`}</p>
|
|
142
|
+
<button class="btn icon ghost rn-close" type="button" data-rn-close aria-label="Close">${I.x}</button>
|
|
143
|
+
</header>
|
|
144
|
+
<div class="rn-body">
|
|
145
|
+
${newer.map((rel) => releaseHtml(rel, 0, { latest: true })).join('')}
|
|
146
|
+
${rels.map((rel, i) => releaseHtml(rel, i + newer.length, { current: rel.version === r.version })).join('')}
|
|
147
|
+
<footer class="rn-foot">All releases live on <a href="https://github.com/DanielCarmichaelGit/heyquilt/releases" target="_blank" rel="noopener">GitHub</a>.</footer>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>`
|
|
151
|
+
document.body.appendChild(back)
|
|
152
|
+
const close = () => back.remove()
|
|
153
|
+
back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
|
|
154
|
+
back.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
|
155
|
+
back.querySelector('[data-rn-close]').onclick = close
|
|
156
|
+
back.querySelector('[data-rn-close]').focus()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
document.addEventListener('click', (e) => {
|
|
160
|
+
if (e.target.closest('[data-release-notes]')) {
|
|
161
|
+
if (state.release) openReleaseNotes()
|
|
162
|
+
else checkRelease().then(openReleaseNotes)
|
|
163
|
+
}
|
|
164
|
+
if (e.target.closest('[data-hide-update]')) { hiddenBar = true; renderUpdateBar() }
|
|
165
|
+
if (e.target.closest('[data-update]')) startUpdate()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// "Don't show this again": hides the bar now, and for good for this release (a newer one shows it again).
|
|
169
|
+
document.addEventListener('change', async (e) => {
|
|
170
|
+
const never = e.target.closest?.('[data-update-never]')
|
|
171
|
+
if (!never || !never.checked || !state.release?.latest) return
|
|
172
|
+
const version = state.release.latest.version
|
|
173
|
+
try {
|
|
174
|
+
await api('POST', '/api/version/hide-bar', { version })
|
|
175
|
+
state.release.barHidden = true
|
|
176
|
+
renderUpdateBar()
|
|
177
|
+
toast(`Hidden until a newer Quilt than ${version} is out. What's New in Quilt still has it.`)
|
|
178
|
+
} catch (err) {
|
|
179
|
+
never.checked = false
|
|
180
|
+
toast(err.message)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Repeat schedules for task cards. Phrases become 5-field cron; the card shows
|
|
2
|
+
// the phrase back in words. No DOM, so the session server can import it too.
|
|
3
|
+
|
|
4
|
+
const INVISIBLE = /[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g
|
|
5
|
+
const RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]]
|
|
6
|
+
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
|
7
|
+
const DOW = {
|
|
8
|
+
sunday: 0, sun: 0, monday: 1, mon: 1, tuesday: 2, tue: 2, tues: 2,
|
|
9
|
+
wednesday: 3, wed: 3, thursday: 4, thu: 4, thur: 4, thurs: 4,
|
|
10
|
+
friday: 5, fri: 5, saturday: 6, sat: 6
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function pieceOk (part, min, max) {
|
|
14
|
+
const m = /^(?:(\*)|(\d+)|(\d+-\d+))(?:\/(\d+))?$/.exec(part)
|
|
15
|
+
if (!m) return false
|
|
16
|
+
if (m[4] != null) {
|
|
17
|
+
const step = Number(m[4])
|
|
18
|
+
if (!Number.isInteger(step) || step < 1 || step > max) return false
|
|
19
|
+
}
|
|
20
|
+
if (m[1]) return true
|
|
21
|
+
if (m[2]) {
|
|
22
|
+
const n = Number(m[2])
|
|
23
|
+
return n >= min && n <= max
|
|
24
|
+
}
|
|
25
|
+
const [a, b] = m[3].split('-').map(Number)
|
|
26
|
+
return a >= min && b <= max && a <= b
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fieldOk (field, min, max) {
|
|
30
|
+
if (!field || field.length > 40) return false
|
|
31
|
+
return field.split(',').every((part) => pieceOk(part, min, max))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True for a 5-field cron expression (numbers, lists, ranges, stars). */
|
|
35
|
+
export function isCron (value) {
|
|
36
|
+
const parts = String(value ?? '').trim().split(/\s+/)
|
|
37
|
+
if (parts.length !== 5) return false
|
|
38
|
+
return parts.every((field, i) => fieldOk(field, RANGES[i][0], RANGES[i][1]))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clock (hour, minute, ampm) {
|
|
42
|
+
let h = Number(hour)
|
|
43
|
+
const m = minute == null || minute === '' ? 0 : Number(minute)
|
|
44
|
+
if (!Number.isInteger(h) || !Number.isInteger(m) || m < 0 || m > 59) return null
|
|
45
|
+
if (ampm) {
|
|
46
|
+
const ap = ampm.toLowerCase()
|
|
47
|
+
if (h > 12 || h < 1) return null
|
|
48
|
+
if (ap === 'pm' && h < 12) h += 12
|
|
49
|
+
if (ap === 'am' && h === 12) h = 0
|
|
50
|
+
} else if (h > 23 || h < 0) return null
|
|
51
|
+
return { h, m }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function phraseToCron (raw) {
|
|
55
|
+
const s = raw.toLowerCase()
|
|
56
|
+
if (s === 'every minute' || s === 'minutely') return '* * * * *'
|
|
57
|
+
if (s === 'hourly' || s === 'every hour') return '0 * * * *'
|
|
58
|
+
if (s === 'daily' || s === 'every day') return '0 0 * * *'
|
|
59
|
+
if (s === 'weekly' || s === 'every week') return '0 0 * * 1'
|
|
60
|
+
if (s === 'monthly' || s === 'every month') return '0 0 1 * *'
|
|
61
|
+
if (s === 'weekdays' || s === 'every weekday') return '0 0 * * 1-5'
|
|
62
|
+
if (s === 'weekends' || s === 'every weekend') return '0 0 * * 0,6'
|
|
63
|
+
|
|
64
|
+
let m = s.match(/^every (\d{1,2}) minutes?$/)
|
|
65
|
+
if (m) {
|
|
66
|
+
const n = Number(m[1])
|
|
67
|
+
if (n < 1 || n > 59) return null
|
|
68
|
+
return n === 1 ? '* * * * *' : `*/${n} * * * *`
|
|
69
|
+
}
|
|
70
|
+
m = s.match(/^every (\d{1,2}) hours?$/)
|
|
71
|
+
if (m) {
|
|
72
|
+
const n = Number(m[1])
|
|
73
|
+
if (n < 1 || n > 23) return null
|
|
74
|
+
return n === 1 ? '0 * * * *' : `0 */${n} * * *`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
m = s.match(/^(every day|daily|weekdays|every weekday|weekends|every weekend|weekly|every week|monthly|every month) at (\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/)
|
|
78
|
+
if (m) {
|
|
79
|
+
const c = clock(m[2], m[3], m[4])
|
|
80
|
+
if (!c) return null
|
|
81
|
+
const kind = m[1]
|
|
82
|
+
if (kind === 'monthly' || kind === 'every month') return `${c.m} ${c.h} 1 * *`
|
|
83
|
+
const dow = kind === 'weekdays' || kind === 'every weekday' ? '1-5'
|
|
84
|
+
: kind === 'weekends' || kind === 'every weekend' ? '0,6'
|
|
85
|
+
: kind === 'weekly' || kind === 'every week' ? '1'
|
|
86
|
+
: '*'
|
|
87
|
+
return `${c.m} ${c.h} * * ${dow}`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
m = s.match(/^(?:every|on)\s+(sunday|sun|monday|mon|tuesday|tue|tues|wednesday|wed|thursday|thu|thur|thurs|friday|fri|saturday|sat)(?: at (\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/)
|
|
91
|
+
if (m) {
|
|
92
|
+
const c = m[2] != null ? clock(m[2], m[3], m[4]) : { h: 0, m: 0 }
|
|
93
|
+
if (!c) return null
|
|
94
|
+
return `${c.m} ${c.h} * * ${DOW[m[1]]}`
|
|
95
|
+
}
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A stored schedule or a phrase ("daily at 9", "weekdays at 9:30", "every monday").
|
|
101
|
+
* Empty clears it. Throws when it is neither a phrase nor 5-field cron.
|
|
102
|
+
*/
|
|
103
|
+
export function parseSchedule (input) {
|
|
104
|
+
const raw = String(input ?? '').replace(INVISIBLE, ' ').replace(/\s+/g, ' ').trim()
|
|
105
|
+
if (!raw) return ''
|
|
106
|
+
if (raw.length > 80) throw new Error('schedule is too long')
|
|
107
|
+
const phrase = phraseToCron(raw)
|
|
108
|
+
if (phrase) return phrase
|
|
109
|
+
if (!isCron(raw)) throw new Error('use 5-field cron, or a phrase like "daily at 9" or "weekdays at 9:30"')
|
|
110
|
+
return raw
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The cron as stored, or null when it is not already in canonical form. Missing is empty. */
|
|
114
|
+
export function canonicalCron (value) {
|
|
115
|
+
if (value == null || value === '') return ''
|
|
116
|
+
if (typeof value !== 'string' || value.length > 80) return null
|
|
117
|
+
try {
|
|
118
|
+
const parsed = parseSchedule(value)
|
|
119
|
+
return parsed === value ? value : null
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clockLabel (hour, minute) {
|
|
126
|
+
const h = Number(hour)
|
|
127
|
+
const m = Number(minute)
|
|
128
|
+
const ap = h >= 12 ? 'pm' : 'am'
|
|
129
|
+
const h12 = h % 12 || 12
|
|
130
|
+
return m === 0 ? `${h12}${ap}` : `${h12}:${String(m).padStart(2, '0')}${ap}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** English for the schedules the card knows. Anything else is the cron itself. */
|
|
134
|
+
export function cronToText (cron) {
|
|
135
|
+
const c = String(cron || '').trim()
|
|
136
|
+
if (!c) return ''
|
|
137
|
+
const parts = c.split(/\s+/)
|
|
138
|
+
if (parts.length !== 5 || !isCron(c)) return c
|
|
139
|
+
const [min, hour, dom, mon, dow] = parts
|
|
140
|
+
if (mon !== '*') return c
|
|
141
|
+
if (c === '* * * * *') return 'Every minute'
|
|
142
|
+
const everyMin = /^\*\/(\d+)$/.exec(min)
|
|
143
|
+
if (everyMin && hour === '*' && dom === '*' && dow === '*') return `Every ${everyMin[1]} minutes`
|
|
144
|
+
if (min === '0' && hour === '*' && dom === '*' && dow === '*') return 'Every hour'
|
|
145
|
+
const everyHour = /^\*\/(\d+)$/.exec(hour)
|
|
146
|
+
if (min === '0' && everyHour && dom === '*' && dow === '*') return `Every ${everyHour[1]} hours`
|
|
147
|
+
if (!/^\d+$/.test(min) || !/^\d+$/.test(hour)) return c
|
|
148
|
+
const when = clockLabel(hour, min)
|
|
149
|
+
if (dom === '*' && dow === '*') return `Every day at ${when}`
|
|
150
|
+
if (dom === '*' && dow === '1-5') return `Weekdays at ${when}`
|
|
151
|
+
if (dom === '*' && dow === '0,6') return `Weekends at ${when}`
|
|
152
|
+
if (dom === '1' && dow === '*') return `Monthly on the 1st at ${when}`
|
|
153
|
+
if (dom === '*' && /^\d$/.test(dow)) return `Every ${DAYS[Number(dow)]} at ${when}`
|
|
154
|
+
return c
|
|
155
|
+
}
|
|
156
|
+
|