@profullstack/agenticjobs 0.12.0 → 0.14.1
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/README.md +70 -1
- package/dist/cli/args.js +1 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/index.js +78 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/client/client.d.ts +44 -0
- package/dist/client/client.js +19 -0
- package/dist/client/client.js.map +1 -1
- package/dist/client/config.js +4 -3
- package/dist/client/config.js.map +1 -1
- package/dist/client/fanout.js +13 -4
- package/dist/client/fanout.js.map +1 -1
- package/dist/client/login.js +9 -2
- package/dist/client/login.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/core/agentwriter.js +22 -5
- package/dist/core/agentwriter.js.map +1 -1
- package/dist/core/applications.js +1 -1
- package/dist/core/applications.js.map +1 -1
- package/dist/core/capacity.js +7 -3
- package/dist/core/capacity.js.map +1 -1
- package/dist/core/import.js +10 -2
- package/dist/core/import.js.map +1 -1
- package/dist/core/jobs.d.ts +24 -6
- package/dist/core/jobs.js +116 -31
- package/dist/core/jobs.js.map +1 -1
- package/dist/core/recommendations.d.ts +134 -0
- package/dist/core/recommendations.js +356 -0
- package/dist/core/recommendations.js.map +1 -0
- package/dist/core/updates.d.ts +15 -3
- package/dist/core/updates.js +58 -24
- package/dist/core/updates.js.map +1 -1
- package/dist/core/zip.js +29 -4
- package/dist/core/zip.js.map +1 -1
- package/dist/directory/federate.js +3 -1
- package/dist/directory/federate.js.map +1 -1
- package/dist/directory/fetch.d.ts +1 -1
- package/dist/markup/markdown.js +2 -1
- package/dist/markup/markdown.js.map +1 -1
- package/dist/markup/resume.js +18 -5
- package/dist/markup/resume.js.map +1 -1
- package/dist/mcp/tools.js +99 -7
- package/dist/mcp/tools.js.map +1 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.js +1 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/instance.js +29 -0
- package/dist/schema/instance.js.map +1 -1
- package/dist/schema/job.d.ts +12 -0
- package/dist/schema/job.js +17 -3
- package/dist/schema/job.js.map +1 -1
- package/dist/schema/jsonld.js +7 -6
- package/dist/schema/jsonld.js.map +1 -1
- package/dist/schema/pay.d.ts +173 -0
- package/dist/schema/pay.js +668 -0
- package/dist/schema/pay.js.map +1 -0
- package/dist/schema/query.js +1 -1
- package/dist/schema/query.js.map +1 -1
- package/dist/server/app.js +3 -1
- package/dist/server/app.js.map +1 -1
- package/dist/server/middleware.d.ts +11 -1
- package/dist/server/middleware.js +18 -3
- package/dist/server/middleware.js.map +1 -1
- package/dist/server/routes/api.js +153 -7
- package/dist/server/routes/api.js.map +1 -1
- package/dist/server/routes/discovery.js +10 -8
- package/dist/server/routes/discovery.js.map +1 -1
- package/dist/server/routes/openapi.js +136 -2
- package/dist/server/routes/openapi.js.map +1 -1
- package/dist/server/routes/pages.js +155 -14
- package/dist/server/routes/pages.js.map +1 -1
- package/dist/tui/views.js +9 -3
- package/dist/tui/views.js.map +1 -1
- package/dist/views/candidates.d.ts +6 -0
- package/dist/views/candidates.js +2 -1
- package/dist/views/candidates.js.map +1 -1
- package/dist/views/inbox.js +6 -6
- package/dist/views/inbox.js.map +1 -1
- package/dist/views/jobs.d.ts +16 -0
- package/dist/views/jobs.js +33 -11
- package/dist/views/jobs.js.map +1 -1
- package/dist/views/layout.d.ts +22 -0
- package/dist/views/layout.js +18 -0
- package/dist/views/layout.js.map +1 -1
- package/dist/views/me.d.ts +7 -0
- package/dist/views/me.js +3 -2
- package/dist/views/me.js.map +1 -1
- package/dist/views/post.d.ts +18 -0
- package/dist/views/post.js +39 -7
- package/dist/views/post.js.map +1 -1
- package/dist/views/recommendations.d.ts +43 -0
- package/dist/views/recommendations.js +26 -0
- package/dist/views/recommendations.js.map +1 -0
- package/dist/views/updates.js +5 -5
- package/dist/views/updates.js.map +1 -1
- package/docs/openjob.md +63 -0
- package/migrations/0015_pay_lines.sql +38 -0
- package/migrations/0016_recommendations.sql +65 -0
- package/package.json +1 -1
- package/web/public/app.css +60 -5
- package/web/public/sw.js +6 -2
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a listing pays, in the words people use to say it.
|
|
3
|
+
*
|
|
4
|
+
* A salary range per year was the whole vocabulary, and it described one
|
|
5
|
+
* kind of work. The listings this board is for pay per task, per pull
|
|
6
|
+
* request, per social post, a flat fee for a project, a share of revenue, or
|
|
7
|
+
* a bounty, in dollars or in a coin, settled over whatever rail the two sides
|
|
8
|
+
* agree on. ugig.net has carried that vocabulary for a year (`budget_type`,
|
|
9
|
+
* `budget_unit`, `payment_coin`) and this is the same model, so a gig there
|
|
10
|
+
* and a job here describe pay the same way and a shared package can lift it
|
|
11
|
+
* later without either side changing shape.
|
|
12
|
+
*
|
|
13
|
+
* Three ideas, kept apart on purpose:
|
|
14
|
+
*
|
|
15
|
+
* - A LINE is one price: an amount or range, what it is per, and what it
|
|
16
|
+
* is denominated in. A listing may carry several: "$0.25 per task" and
|
|
17
|
+
* "$0.25 per PR that fixes a bug" are two lines, not one with a note.
|
|
18
|
+
* - The DENOMINATION is what the number is written in: USD, EUR, or a
|
|
19
|
+
* ticker such as SOL. "$0.25 per task" is denominated in dollars even
|
|
20
|
+
* when it is paid in SOL.
|
|
21
|
+
* - The METHOD is the rail it is settled over: SOL, USDC, a bank transfer,
|
|
22
|
+
* PayPal, payroll. One per listing, separate from the price, because
|
|
23
|
+
* "$100 an hour paid in USDC" is one rate with a preference, not two
|
|
24
|
+
* rates - the number does not change because the rail did.
|
|
25
|
+
*
|
|
26
|
+
* Everything here is text in, text out. A person types "$0.25 per task", an
|
|
27
|
+
* agent sends the same string, the front matter of a job file carries it as a
|
|
28
|
+
* list, and the page prints it back. The structured form is what the API
|
|
29
|
+
* serves, but the string is what everybody writes, so the parser is the
|
|
30
|
+
* feature and the JSON is its shadow.
|
|
31
|
+
*
|
|
32
|
+
* No imports from the rest of the board: this file is meant to be lifted into
|
|
33
|
+
* a shared package with ugig.net and CoinPay behind it.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* The kinds of price. The same list ugig.net's `budget_type` carries, in the
|
|
37
|
+
* same spelling, so a value round-trips between the two boards untouched.
|
|
38
|
+
*
|
|
39
|
+
* hourly … yearly a rate per unit of time
|
|
40
|
+
* fixed a flat fee for the whole engagement
|
|
41
|
+
* per_task a price per task, the unit being "task"
|
|
42
|
+
* per_unit a price per anything else, named in `unit`
|
|
43
|
+
* revenue_share a percentage; min and max are percentages
|
|
44
|
+
* bounty a fixed payout for an accepted submission
|
|
45
|
+
*/
|
|
46
|
+
export const PAY_TYPES = [
|
|
47
|
+
'hourly',
|
|
48
|
+
'daily',
|
|
49
|
+
'weekly',
|
|
50
|
+
'monthly',
|
|
51
|
+
'yearly',
|
|
52
|
+
'fixed',
|
|
53
|
+
'per_task',
|
|
54
|
+
'per_unit',
|
|
55
|
+
'revenue_share',
|
|
56
|
+
'bounty',
|
|
57
|
+
];
|
|
58
|
+
/**
|
|
59
|
+
* Coins a listing is commonly settled in. ugig.net's list, which is the set
|
|
60
|
+
* CoinPay can take payment in; Lightning and on-chain BTC are deliberately
|
|
61
|
+
* not on it because CoinPay cannot settle them. The method field is free
|
|
62
|
+
* text and accepts anything, so this list only drives the suggestions in a
|
|
63
|
+
* form and the upper-casing of a ticker somebody typed in lowercase.
|
|
64
|
+
*/
|
|
65
|
+
export const PAYMENT_COINS = ['SOL', 'ETH', 'USDC', 'USDT', 'POL'];
|
|
66
|
+
/** Rails that are not coins, offered beside them in a form. */
|
|
67
|
+
export const PAYMENT_RAILS = ['bank transfer', 'PayPal', 'payroll', 'card', 'invoice'];
|
|
68
|
+
export const EMPTY_PAY = { lines: [], method: null, equity: null, unpaid: false };
|
|
69
|
+
/** The most lines a listing can carry. Past this it is a rate card, not a job. */
|
|
70
|
+
export const PAY_LINES_MAX = 8;
|
|
71
|
+
/** The longest a per-unit description can be: "PR that fixes a bug you find". */
|
|
72
|
+
export const UNIT_MAX = 60;
|
|
73
|
+
export const METHOD_MAX = 40;
|
|
74
|
+
export const EQUITY_MAX = 60;
|
|
75
|
+
// --- currencies ----------------------------------------------------------
|
|
76
|
+
const FIAT = new Set([
|
|
77
|
+
'USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD', 'CHF', 'SEK', 'NOK', 'NZD', 'INR', 'CNY',
|
|
78
|
+
'BRL', 'MXN', 'ZAR', 'SGD', 'HKD', 'PLN', 'DKK', 'KWD',
|
|
79
|
+
]);
|
|
80
|
+
const CRYPTO = new Set([
|
|
81
|
+
'SOL', 'USDC', 'USDT', 'BTC', 'ETH', 'POL', 'MATIC', 'BNB', 'XRP', 'DOGE', 'LTC', 'AVAX',
|
|
82
|
+
'ADA', 'DAI', 'PYUSD', 'BCH', 'SATS',
|
|
83
|
+
]);
|
|
84
|
+
const SYMBOL_TO_CODE = {
|
|
85
|
+
$: 'USD',
|
|
86
|
+
'€': 'EUR',
|
|
87
|
+
'£': 'GBP',
|
|
88
|
+
'¥': 'JPY',
|
|
89
|
+
};
|
|
90
|
+
const CODE_TO_SYMBOL = {
|
|
91
|
+
USD: '$',
|
|
92
|
+
EUR: '€',
|
|
93
|
+
GBP: '£',
|
|
94
|
+
JPY: '¥',
|
|
95
|
+
};
|
|
96
|
+
export const isFiat = (code) => FIAT.has(code.toUpperCase());
|
|
97
|
+
export const isKnownCurrency = (code) => FIAT.has(code.toUpperCase()) || CRYPTO.has(code.toUpperCase());
|
|
98
|
+
/** The keys of the time-based types, in the period words the rest of the board uses. */
|
|
99
|
+
const PERIOD_TYPE = {
|
|
100
|
+
hour: 'hourly',
|
|
101
|
+
hr: 'hourly',
|
|
102
|
+
h: 'hourly',
|
|
103
|
+
day: 'daily',
|
|
104
|
+
week: 'weekly',
|
|
105
|
+
wk: 'weekly',
|
|
106
|
+
month: 'monthly',
|
|
107
|
+
mo: 'monthly',
|
|
108
|
+
year: 'yearly',
|
|
109
|
+
yr: 'yearly',
|
|
110
|
+
annum: 'yearly',
|
|
111
|
+
};
|
|
112
|
+
const TYPE_PERIOD = {
|
|
113
|
+
hourly: 'hour',
|
|
114
|
+
daily: 'day',
|
|
115
|
+
weekly: 'week',
|
|
116
|
+
monthly: 'month',
|
|
117
|
+
yearly: 'year',
|
|
118
|
+
};
|
|
119
|
+
/** The period word for a time-based line, or null for every other kind. */
|
|
120
|
+
export function periodOf(type) {
|
|
121
|
+
return TYPE_PERIOD[type] ?? null;
|
|
122
|
+
}
|
|
123
|
+
export function isPayType(value) {
|
|
124
|
+
return typeof value === 'string' && PAY_TYPES.includes(value);
|
|
125
|
+
}
|
|
126
|
+
// --- parsing -------------------------------------------------------------
|
|
127
|
+
/** How a pay line is written, for every error message that has to explain it. */
|
|
128
|
+
export const PAY_LINE_EXAMPLES = '"$0.25 per task", "$120k - $150k a year", "$100 an hour", "$5000 fixed", "0.01 SOL per PR that fixes a bug", "10% revenue share"';
|
|
129
|
+
/** "$120k" -> 120000, "1.5m" -> 1500000, "0.25" -> 0.25. */
|
|
130
|
+
function amountOf(digits, scale) {
|
|
131
|
+
const base = Number(digits.replace(/[,_]/g, ''));
|
|
132
|
+
const factor = scale === undefined ? 1 : scale.toLowerCase() === 'k' ? 1_000 : 1_000_000;
|
|
133
|
+
return base * factor;
|
|
134
|
+
}
|
|
135
|
+
/** One money token: an optional symbol or code, digits, an optional k/m.
|
|
136
|
+
* A scale must end before a letter so it cannot consume a ticker or "monthly".
|
|
137
|
+
*/
|
|
138
|
+
const MONEY = /(?:([$€£¥])\s*)?(?:([A-Za-z]{3,5})\s+)?(\d[\d,_]*(?:\.\d+)?)\s*([kKmM](?![A-Za-z]))?(?:\s*([$€£¥])|\s*([A-Za-z]{3,5})\b)?/y;
|
|
139
|
+
/**
|
|
140
|
+
* Words that follow an amount and are not a currency code, so "$100 per
|
|
141
|
+
* hour" does not read "per" as a ticker. A code is taken only when it is one
|
|
142
|
+
* this file knows, or when it was typed in capitals, which is how tickers are
|
|
143
|
+
* written and how nothing else in a pay line is.
|
|
144
|
+
*/
|
|
145
|
+
function codeFrom(raw) {
|
|
146
|
+
if (raw === undefined)
|
|
147
|
+
return null;
|
|
148
|
+
const upper = raw.toUpperCase();
|
|
149
|
+
if (isKnownCurrency(upper))
|
|
150
|
+
return upper;
|
|
151
|
+
if (raw === upper && /^[A-Z]{3,5}$/.test(upper))
|
|
152
|
+
return upper;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
function readMoney(text, at) {
|
|
156
|
+
MONEY.lastIndex = at;
|
|
157
|
+
const match = MONEY.exec(text);
|
|
158
|
+
if (match === null)
|
|
159
|
+
return null;
|
|
160
|
+
const [, symbolBefore, codeBefore, digits, scale, symbolAfter, codeAfter] = match;
|
|
161
|
+
let end = at + match[0].length;
|
|
162
|
+
let currency = null;
|
|
163
|
+
if (symbolBefore !== undefined)
|
|
164
|
+
currency = SYMBOL_TO_CODE[symbolBefore] ?? null;
|
|
165
|
+
if (currency === null && codeBefore !== undefined) {
|
|
166
|
+
currency = codeFrom(codeBefore);
|
|
167
|
+
// A leading word that is not a code is not part of the amount at all.
|
|
168
|
+
if (currency === null)
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (currency === null && symbolAfter !== undefined)
|
|
172
|
+
currency = SYMBOL_TO_CODE[symbolAfter] ?? null;
|
|
173
|
+
if (codeAfter !== undefined) {
|
|
174
|
+
const code = codeFrom(codeAfter);
|
|
175
|
+
if (code !== null) {
|
|
176
|
+
currency ??= code;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// "per", "an", "fixed": give the word back to the rest of the line.
|
|
180
|
+
end -= match[0].length - match[0].lastIndexOf(codeAfter);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (digits === undefined)
|
|
184
|
+
return null;
|
|
185
|
+
return { amount: amountOf(digits, scale), currency, end };
|
|
186
|
+
}
|
|
187
|
+
const REVENUE = /\b(rev(?:enue)?(?:\s+share)?|profit\s+share|of\s+(?:the\s+)?revenue|share)\b/i;
|
|
188
|
+
function parseRevenueShare(text) {
|
|
189
|
+
if (!REVENUE.test(text) || !/%|percent/i.test(text))
|
|
190
|
+
return null;
|
|
191
|
+
const numbers = [...text.matchAll(/(\d+(?:\.\d+)?)\s*(?:%|percent)?/g)].map((m) => Number(m[1]));
|
|
192
|
+
if (numbers.length === 0)
|
|
193
|
+
return 'A revenue share needs a percentage: "10% revenue share".';
|
|
194
|
+
const upTo = /^\s*(up\s*to|max(?:imum)?)\b/i.test(text);
|
|
195
|
+
const from = /^\s*(from|at\s+least|min(?:imum)?)\b/i.test(text) || /\+\s*%?/.test(text);
|
|
196
|
+
const [first, second] = numbers;
|
|
197
|
+
if (first === undefined)
|
|
198
|
+
return 'A revenue share needs a percentage: "10% revenue share".';
|
|
199
|
+
let min = first;
|
|
200
|
+
let max = second ?? null;
|
|
201
|
+
if (second === undefined) {
|
|
202
|
+
if (upTo) {
|
|
203
|
+
min = null;
|
|
204
|
+
max = first;
|
|
205
|
+
}
|
|
206
|
+
else if (!from) {
|
|
207
|
+
max = first;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (min !== null && max !== null && max < min) {
|
|
211
|
+
return 'The top of the revenue share is below the bottom of it.';
|
|
212
|
+
}
|
|
213
|
+
if ((min ?? 0) > 100 || (max ?? 0) > 100)
|
|
214
|
+
return 'A revenue share is at most 100%.';
|
|
215
|
+
return { type: 'revenue_share', min, max, currency: '%', unit: null };
|
|
216
|
+
}
|
|
217
|
+
const FLAT = /^(?:fixed|flat|total|one[- ]off|lump\s+sum|(?:for|per)\s+(?:the\s+)?(?:whole\s+)?(?:project|job|engagement)|project(?:\s+fee)?)\.?$/i;
|
|
218
|
+
const BOUNTY = /^(?:as\s+a\s+)?bounty\.?$/i;
|
|
219
|
+
const PERIOD_ONLY = /^(hourly|daily|weekly|monthly|yearly|annually|annual|p\.?a\.?)\.?$/i;
|
|
220
|
+
const PER = /^(?:(?:per|an|a|each|every|for\s+each|for\s+every)\s+|\/\s*)(.+?)\.?$/i;
|
|
221
|
+
const PERIOD_WORD = {
|
|
222
|
+
hourly: 'hourly',
|
|
223
|
+
daily: 'daily',
|
|
224
|
+
weekly: 'weekly',
|
|
225
|
+
monthly: 'monthly',
|
|
226
|
+
yearly: 'yearly',
|
|
227
|
+
annually: 'yearly',
|
|
228
|
+
annual: 'yearly',
|
|
229
|
+
pa: 'yearly',
|
|
230
|
+
'p.a.': 'yearly',
|
|
231
|
+
'p.a': 'yearly',
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* One pay line from the way a person wrote it.
|
|
235
|
+
*
|
|
236
|
+
* Returns the line, or a sentence saying what could not be read. A sentence
|
|
237
|
+
* rather than a throw, because the caller is a form or an API handler and
|
|
238
|
+
* the person has to be told which line and what it should look like.
|
|
239
|
+
*/
|
|
240
|
+
export function parsePayLine(input) {
|
|
241
|
+
const read = readPayLine(input);
|
|
242
|
+
return typeof read === 'string' ? read : read.line;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* "…, settled in SOL" / "paid in USDC" / "via bank transfer" on the end of a
|
|
246
|
+
* line names the rail, not the unit. It is split off here so the form's
|
|
247
|
+
* one-line-per-price box accepts the sentence people actually write, and the
|
|
248
|
+
* method lands where the rest of the board looks for it.
|
|
249
|
+
*/
|
|
250
|
+
const SETTLED = /[,;]?\s*(?:(?:paid|settled|payable|payment)\s+)?(?:in|via|by|over|through)\s+([A-Za-z][A-Za-z ]{1,30})\.?$/i;
|
|
251
|
+
/** The line and, when the text named one, the rail it is settled over. */
|
|
252
|
+
export function readPayLine(input) {
|
|
253
|
+
const raw = typeof input === 'string' ? input.replace(/\s+/g, ' ').trim() : '';
|
|
254
|
+
if (raw === '')
|
|
255
|
+
return `An empty pay line. Write one like ${PAY_LINE_EXAMPLES}.`;
|
|
256
|
+
if (raw.length > 200)
|
|
257
|
+
return 'A pay line is at most 200 characters.';
|
|
258
|
+
let text = raw;
|
|
259
|
+
let method = null;
|
|
260
|
+
const settled = SETTLED.exec(raw);
|
|
261
|
+
if (settled !== null) {
|
|
262
|
+
const named = normaliseMethod(settled[1]);
|
|
263
|
+
// Only a rail this file recognises is split off. "per hour in London" is
|
|
264
|
+
// a place, and stays part of the line for the person to fix or keep.
|
|
265
|
+
if (named !== null && (isKnownCurrency(named) || isRail(named))) {
|
|
266
|
+
method = named;
|
|
267
|
+
text = raw.slice(0, settled.index).trim();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const line = parseLine(text);
|
|
271
|
+
return typeof line === 'string' ? line : { line, method };
|
|
272
|
+
}
|
|
273
|
+
function isRail(value) {
|
|
274
|
+
const lower = value.toLowerCase();
|
|
275
|
+
return (PAYMENT_RAILS.some((rail) => rail.toLowerCase() === lower) ||
|
|
276
|
+
/^(?:bank|wire|ach|sepa|paypal|stripe|payroll|card|invoice|crypto|stablecoin|lightning)\b/.test(lower));
|
|
277
|
+
}
|
|
278
|
+
function parseLine(text) {
|
|
279
|
+
const share = parseRevenueShare(text);
|
|
280
|
+
if (share !== null)
|
|
281
|
+
return share;
|
|
282
|
+
let cursor = 0;
|
|
283
|
+
let lead = null;
|
|
284
|
+
const leadMatch = /^(from|at least|min(?:imum)?|up to|upto|max(?:imum)?|starting at)\s+/i.exec(text);
|
|
285
|
+
if (leadMatch !== null) {
|
|
286
|
+
const word = (leadMatch[1] ?? '').toLowerCase();
|
|
287
|
+
lead = word.startsWith('up') || word.startsWith('max') ? 'upto' : 'from';
|
|
288
|
+
cursor = leadMatch[0].length;
|
|
289
|
+
}
|
|
290
|
+
const first = readMoney(text, cursor);
|
|
291
|
+
if (first === null) {
|
|
292
|
+
return `Could not find an amount in "${text}". Write it like ${PAY_LINE_EXAMPLES}.`;
|
|
293
|
+
}
|
|
294
|
+
cursor = first.end;
|
|
295
|
+
let second = null;
|
|
296
|
+
const dash = /\s*(?:-|–|—|to)\s*/y;
|
|
297
|
+
dash.lastIndex = cursor;
|
|
298
|
+
const dashMatch = dash.exec(text);
|
|
299
|
+
if (dashMatch !== null) {
|
|
300
|
+
second = readMoney(text, cursor + dashMatch[0].length);
|
|
301
|
+
if (second !== null)
|
|
302
|
+
cursor = second.end;
|
|
303
|
+
}
|
|
304
|
+
let openEnded = false;
|
|
305
|
+
if (second === null && text.charAt(cursor) === '+') {
|
|
306
|
+
openEnded = true;
|
|
307
|
+
cursor += 1;
|
|
308
|
+
}
|
|
309
|
+
const currency = (first.currency ?? second?.currency ?? 'USD').toUpperCase();
|
|
310
|
+
if (second !== null && second.currency !== null && second.currency !== currency) {
|
|
311
|
+
return `"${text}" mixes two currencies. A range is written in one.`;
|
|
312
|
+
}
|
|
313
|
+
let min = first.amount;
|
|
314
|
+
let max = second?.amount ?? null;
|
|
315
|
+
if (second === null) {
|
|
316
|
+
if (lead === 'upto') {
|
|
317
|
+
min = null;
|
|
318
|
+
max = first.amount;
|
|
319
|
+
}
|
|
320
|
+
else if (lead !== 'from' && !openEnded) {
|
|
321
|
+
max = first.amount;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (min !== null && max !== null && max < min) {
|
|
325
|
+
return `The top of "${text}" is below the bottom of it.`;
|
|
326
|
+
}
|
|
327
|
+
const rest = text.slice(cursor).trim().replace(/^[,;:]\s*/, '');
|
|
328
|
+
if (rest === '') {
|
|
329
|
+
return `"${text}" says how much but not what for. Add "a year", "an hour", "fixed" or "per task".`;
|
|
330
|
+
}
|
|
331
|
+
const base = { min, max, currency };
|
|
332
|
+
if (FLAT.test(rest))
|
|
333
|
+
return { ...base, type: 'fixed', unit: null };
|
|
334
|
+
if (BOUNTY.test(rest))
|
|
335
|
+
return { ...base, type: 'bounty', unit: null };
|
|
336
|
+
const periodOnly = PERIOD_ONLY.exec(rest);
|
|
337
|
+
if (periodOnly !== null) {
|
|
338
|
+
const type = PERIOD_WORD[(periodOnly[1] ?? '').toLowerCase()];
|
|
339
|
+
if (type !== undefined)
|
|
340
|
+
return { ...base, type, unit: null };
|
|
341
|
+
}
|
|
342
|
+
const per = PER.exec(rest);
|
|
343
|
+
if (per !== null) {
|
|
344
|
+
const what = (per[1] ?? '').trim();
|
|
345
|
+
const period = PERIOD_TYPE[what.toLowerCase().replace(/s$/, '')];
|
|
346
|
+
if (period !== undefined)
|
|
347
|
+
return { ...base, type: period, unit: null };
|
|
348
|
+
if (/^(?:project|job|engagement)$/i.test(what))
|
|
349
|
+
return { ...base, type: 'fixed', unit: null };
|
|
350
|
+
if (/^tasks?$/i.test(what))
|
|
351
|
+
return { ...base, type: 'per_task', unit: 'task' };
|
|
352
|
+
if (what.length > UNIT_MAX)
|
|
353
|
+
return `The unit in "${text}" is too long: at most ${UNIT_MAX} characters.`;
|
|
354
|
+
return { ...base, type: 'per_unit', unit: what };
|
|
355
|
+
}
|
|
356
|
+
return `Could not read "${rest}" in "${text}". Write it like ${PAY_LINE_EXAMPLES}.`;
|
|
357
|
+
}
|
|
358
|
+
// --- formatting ----------------------------------------------------------
|
|
359
|
+
/** "$0.25", "$120k", "0.01 SOL", "CAD 1,000", "10%". */
|
|
360
|
+
export function formatAmount(amount, currency) {
|
|
361
|
+
const code = currency.toUpperCase();
|
|
362
|
+
if (code === '%')
|
|
363
|
+
return `${trimNumber(amount)}%`;
|
|
364
|
+
const symbol = CODE_TO_SYMBOL[code];
|
|
365
|
+
if (symbol !== undefined) {
|
|
366
|
+
if (amount >= 1000 && amount % 1000 === 0)
|
|
367
|
+
return `${symbol}${amount / 1000}k`;
|
|
368
|
+
return `${symbol}${trimNumber(amount)}`;
|
|
369
|
+
}
|
|
370
|
+
if (isFiat(code)) {
|
|
371
|
+
if (amount >= 1000 && amount % 1000 === 0)
|
|
372
|
+
return `${code} ${amount / 1000}k`;
|
|
373
|
+
return `${code} ${trimNumber(amount)}`;
|
|
374
|
+
}
|
|
375
|
+
// A coin amount is a quantity: 0.5 SOL is 0.5 SOL, not 0.50000000 SOL.
|
|
376
|
+
return `${trimNumber(amount)} ${code}`;
|
|
377
|
+
}
|
|
378
|
+
function trimNumber(amount) {
|
|
379
|
+
if (Number.isInteger(amount))
|
|
380
|
+
return amount.toLocaleString('en-US');
|
|
381
|
+
return amount.toLocaleString('en-US', { maximumSignificantDigits: 21 });
|
|
382
|
+
}
|
|
383
|
+
function suffixOf(line) {
|
|
384
|
+
switch (line.type) {
|
|
385
|
+
case 'hourly':
|
|
386
|
+
return 'an hour';
|
|
387
|
+
case 'daily':
|
|
388
|
+
return 'a day';
|
|
389
|
+
case 'weekly':
|
|
390
|
+
return 'a week';
|
|
391
|
+
case 'monthly':
|
|
392
|
+
return 'a month';
|
|
393
|
+
case 'yearly':
|
|
394
|
+
return 'a year';
|
|
395
|
+
case 'fixed':
|
|
396
|
+
return 'fixed';
|
|
397
|
+
case 'bounty':
|
|
398
|
+
return 'bounty';
|
|
399
|
+
case 'per_task':
|
|
400
|
+
return `per ${line.unit ?? 'task'}`;
|
|
401
|
+
case 'per_unit':
|
|
402
|
+
return `per ${line.unit ?? 'unit'}`;
|
|
403
|
+
case 'revenue_share':
|
|
404
|
+
return 'revenue share';
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* One line, the way it is printed everywhere: the badge on a card, the CLI,
|
|
409
|
+
* the TUI, a federated result. A single endpoint is a bound rather than a
|
|
410
|
+
* fixed figure, and is labelled as one.
|
|
411
|
+
*/
|
|
412
|
+
export function formatPayLine(line) {
|
|
413
|
+
const suffix = suffixOf(line);
|
|
414
|
+
const { min, max } = line;
|
|
415
|
+
if (min === null && max === null)
|
|
416
|
+
return suffix;
|
|
417
|
+
const money = (amount) => formatAmount(amount, line.currency);
|
|
418
|
+
if (min === null)
|
|
419
|
+
return `Up to ${money(max)} ${suffix}`;
|
|
420
|
+
if (max === null)
|
|
421
|
+
return `From ${money(min)} ${suffix}`;
|
|
422
|
+
const range = min === max ? money(min) : `${money(min)} - ${money(max)}`;
|
|
423
|
+
return `${range} ${suffix}`;
|
|
424
|
+
}
|
|
425
|
+
/** Every line, or "Unpaid", or null when the listing says nothing about pay. */
|
|
426
|
+
export function formatPay(pay) {
|
|
427
|
+
if (pay.unpaid)
|
|
428
|
+
return 'Unpaid';
|
|
429
|
+
const lines = pay.lines.filter((line) => line.min !== null || line.max !== null);
|
|
430
|
+
if (lines.length === 0)
|
|
431
|
+
return null;
|
|
432
|
+
return lines.map(formatPayLine).join(', ');
|
|
433
|
+
}
|
|
434
|
+
/** The first line only, for a badge with one line of room. */
|
|
435
|
+
export function formatPayShort(pay) {
|
|
436
|
+
if (pay.unpaid)
|
|
437
|
+
return 'Unpaid';
|
|
438
|
+
const first = pay.lines.find((line) => line.min !== null || line.max !== null);
|
|
439
|
+
if (first === undefined)
|
|
440
|
+
return null;
|
|
441
|
+
const more = pay.lines.length - 1;
|
|
442
|
+
return more > 0 ? `${formatPayLine(first)} +${more}` : formatPayLine(first);
|
|
443
|
+
}
|
|
444
|
+
/** "Paid in SOL", "Paid by bank transfer". */
|
|
445
|
+
export function formatMethod(method) {
|
|
446
|
+
if (method === null || method.trim() === '')
|
|
447
|
+
return null;
|
|
448
|
+
const upper = method.toUpperCase();
|
|
449
|
+
const isTicker = method === upper && /^[A-Z]{2,6}$/.test(upper);
|
|
450
|
+
return isTicker ? `Paid in ${upper}` : `Paid by ${method}`;
|
|
451
|
+
}
|
|
452
|
+
/** Has the employer said what it pays? Unpaid counts: it is a statement. */
|
|
453
|
+
export function payStated(pay) {
|
|
454
|
+
if (pay.unpaid)
|
|
455
|
+
return true;
|
|
456
|
+
return pay.lines.some((line) => line.min !== null || line.max !== null);
|
|
457
|
+
}
|
|
458
|
+
/** A listing from a board that predates pay lines, read into the new shape. */
|
|
459
|
+
export function payFromSalary(salary) {
|
|
460
|
+
const type = PERIOD_TYPE[salary.period] ?? 'yearly';
|
|
461
|
+
const stated = salary.min !== null || salary.max !== null;
|
|
462
|
+
return {
|
|
463
|
+
lines: stated
|
|
464
|
+
? [{ type, min: salary.min, max: salary.max, currency: salary.currency.toUpperCase(), unit: null }]
|
|
465
|
+
: [],
|
|
466
|
+
method: null,
|
|
467
|
+
equity: salary.equity ?? null,
|
|
468
|
+
unpaid: salary.unpaid === true,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* The time-based line a listing's `salary` columns carry, for search, sort
|
|
473
|
+
* and structured data - all of which compare on an annual basis and have no
|
|
474
|
+
* meaning for a price per task. The first time-based line wins; a listing
|
|
475
|
+
* with none has a null range, so it stays out of every salary filter.
|
|
476
|
+
*/
|
|
477
|
+
export function salaryFromPay(pay) {
|
|
478
|
+
const line = pay.unpaid ? undefined : pay.lines.find((entry) => periodOf(entry.type) !== null);
|
|
479
|
+
if (line === undefined) {
|
|
480
|
+
const first = pay.lines[0];
|
|
481
|
+
return { min: null, max: null, currency: first?.currency ?? 'USD', period: 'year' };
|
|
482
|
+
}
|
|
483
|
+
// The columns are integers. A rate under a unit rounds to nothing, and
|
|
484
|
+
// nothing is more honest than "From $0".
|
|
485
|
+
const whole = (value) => value === null ? null : Math.round(value) === 0 ? null : Math.round(value);
|
|
486
|
+
return {
|
|
487
|
+
min: whole(line.min),
|
|
488
|
+
max: whole(line.max),
|
|
489
|
+
currency: line.currency,
|
|
490
|
+
period: periodOf(line.type) ?? 'year',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
// --- from a request ------------------------------------------------------
|
|
494
|
+
function truthy(value) {
|
|
495
|
+
if (value === true)
|
|
496
|
+
return true;
|
|
497
|
+
if (typeof value === 'string')
|
|
498
|
+
return ['true', 'on', 'yes', '1'].includes(value.trim().toLowerCase());
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
function cleanText(value, max) {
|
|
502
|
+
if (typeof value !== 'string')
|
|
503
|
+
return '';
|
|
504
|
+
return value.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
505
|
+
}
|
|
506
|
+
/** A ticker typed in any case comes out in capitals; a rail stays as typed. */
|
|
507
|
+
export function normaliseMethod(value) {
|
|
508
|
+
const text = cleanText(value, METHOD_MAX);
|
|
509
|
+
if (text === '')
|
|
510
|
+
return null;
|
|
511
|
+
const upper = text.toUpperCase();
|
|
512
|
+
if (PAYMENT_COINS.includes(upper) || CRYPTO.has(upper))
|
|
513
|
+
return upper;
|
|
514
|
+
return text;
|
|
515
|
+
}
|
|
516
|
+
function lineFromObject(value) {
|
|
517
|
+
const type = value['type'];
|
|
518
|
+
if (!isPayType(type)) {
|
|
519
|
+
return `A pay line needs a type from ${PAY_TYPES.join(', ')}, or a "text" such as "$0.25 per task".`;
|
|
520
|
+
}
|
|
521
|
+
const number = (raw) => {
|
|
522
|
+
if (raw === null || raw === undefined || raw === '')
|
|
523
|
+
return null;
|
|
524
|
+
const n = typeof raw === 'number' ? raw : Number(String(raw).replace(/[,_$]/g, ''));
|
|
525
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
526
|
+
};
|
|
527
|
+
// `amount` is a single figure: ugig writes a fixed price as min = max, and
|
|
528
|
+
// an object with only `min` is an open floor, printed "From".
|
|
529
|
+
const amount = number(value['amount']);
|
|
530
|
+
const min = amount ?? number(value['min']);
|
|
531
|
+
const max = amount ?? number(value['max']);
|
|
532
|
+
if (min !== null && max !== null && max < min)
|
|
533
|
+
return 'The top of a pay range is below the bottom of it.';
|
|
534
|
+
if (type === 'revenue_share' && ((min ?? 0) > 100 || (max ?? 0) > 100)) {
|
|
535
|
+
return 'A revenue share is at most 100%.';
|
|
536
|
+
}
|
|
537
|
+
const currency = type === 'revenue_share' ? '%' : cleanText(value['currency'], 5).toUpperCase() || 'USD';
|
|
538
|
+
const unit = cleanText(value['unit'], UNIT_MAX) || null;
|
|
539
|
+
return {
|
|
540
|
+
type,
|
|
541
|
+
min,
|
|
542
|
+
max,
|
|
543
|
+
currency,
|
|
544
|
+
unit: type === 'per_task' ? (unit ?? 'task') : type === 'per_unit' ? unit : null,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Pay, from whatever a form, an API client or a model sent.
|
|
549
|
+
*
|
|
550
|
+
* `pay` may be a string (one line, or several separated by newlines), an
|
|
551
|
+
* array of strings or of line objects, or an object with `lines` and
|
|
552
|
+
* `method`. The old flat fields - salaryMin, salaryMax, salaryCurrency,
|
|
553
|
+
* salaryPeriod, salaryUnpaid, salaryEquity - still work and become one line,
|
|
554
|
+
* so a client written against 0.11 keeps posting listings.
|
|
555
|
+
*
|
|
556
|
+
* Returns the pay, or a sentence saying which line could not be read.
|
|
557
|
+
*/
|
|
558
|
+
export function normalisePay(input) {
|
|
559
|
+
const raw = input['pay'] ?? input['payLines'];
|
|
560
|
+
const nested = typeof raw === 'object' && raw !== null && !Array.isArray(raw)
|
|
561
|
+
? raw
|
|
562
|
+
: null;
|
|
563
|
+
const unpaid = truthy(input['salaryUnpaid']) ||
|
|
564
|
+
truthy(input['payUnpaid']) ||
|
|
565
|
+
truthy(input['unpaid']) ||
|
|
566
|
+
(nested !== null && truthy(nested['unpaid']));
|
|
567
|
+
const items = [];
|
|
568
|
+
const source = nested !== null ? nested['lines'] : raw;
|
|
569
|
+
if (typeof source === 'string') {
|
|
570
|
+
items.push(...source.split(/\r?\n|;/).map((line) => line.trim()).filter((line) => line !== ''));
|
|
571
|
+
}
|
|
572
|
+
else if (Array.isArray(source)) {
|
|
573
|
+
items.push(...source);
|
|
574
|
+
}
|
|
575
|
+
else if (nested !== null && nested['lines'] === undefined && isPayType(nested['type'])) {
|
|
576
|
+
items.push(nested);
|
|
577
|
+
}
|
|
578
|
+
const lines = [];
|
|
579
|
+
let saidUnpaid = unpaid;
|
|
580
|
+
let namedMethod = null;
|
|
581
|
+
for (const item of items) {
|
|
582
|
+
if (typeof item === 'string' && /^unpaid\.?$/i.test(item.trim())) {
|
|
583
|
+
saidUnpaid = true;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (typeof item === 'object' && item !== null) {
|
|
587
|
+
const object = item;
|
|
588
|
+
if (typeof object['text'] === 'string') {
|
|
589
|
+
const read = readPayLine(object['text']);
|
|
590
|
+
if (typeof read === 'string')
|
|
591
|
+
return read;
|
|
592
|
+
lines.push(read.line);
|
|
593
|
+
if (read.method !== null)
|
|
594
|
+
namedMethod ??= read.method;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const line = lineFromObject(object);
|
|
598
|
+
if (typeof line === 'string')
|
|
599
|
+
return line;
|
|
600
|
+
lines.push(line);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const read = readPayLine(item);
|
|
604
|
+
if (typeof read === 'string')
|
|
605
|
+
return read;
|
|
606
|
+
lines.push(read.line);
|
|
607
|
+
if (read.method !== null)
|
|
608
|
+
namedMethod ??= read.method;
|
|
609
|
+
}
|
|
610
|
+
// The flat fields, when nothing newer was sent.
|
|
611
|
+
if (lines.length === 0 && !saidUnpaid) {
|
|
612
|
+
const money = (value) => {
|
|
613
|
+
if (value === null || value === undefined || value === '')
|
|
614
|
+
return null;
|
|
615
|
+
const n = typeof value === 'number' ? value : Number(String(value).replace(/[,_$]/g, ''));
|
|
616
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
617
|
+
};
|
|
618
|
+
const min = money(input['salaryMin']);
|
|
619
|
+
const max = money(input['salaryMax']);
|
|
620
|
+
if (min !== null || max !== null) {
|
|
621
|
+
if (min !== null && max !== null && max < min) {
|
|
622
|
+
return 'The top of the salary range is below the bottom of it.';
|
|
623
|
+
}
|
|
624
|
+
const period = typeof input['salaryPeriod'] === 'string' ? input['salaryPeriod'] : 'year';
|
|
625
|
+
lines.push({
|
|
626
|
+
type: PERIOD_TYPE[period] ?? 'yearly',
|
|
627
|
+
min,
|
|
628
|
+
max,
|
|
629
|
+
currency: (cleanText(input['salaryCurrency'], 5) || 'USD').toUpperCase(),
|
|
630
|
+
unit: null,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (lines.length > PAY_LINES_MAX)
|
|
635
|
+
return `At most ${PAY_LINES_MAX} pay lines.`;
|
|
636
|
+
const method = normaliseMethod(input['payMethod'] ?? input['paymentMethod'] ?? input['paymentCoin'] ?? nested?.['method']) ?? namedMethod;
|
|
637
|
+
const equity = cleanText(input['payEquity'] ?? input['salaryEquity'] ?? input['equity'] ?? nested?.['equity'], EQUITY_MAX) ||
|
|
638
|
+
null;
|
|
639
|
+
// Unpaid wins over any number that came with it. A form can post a stale
|
|
640
|
+
// line beside a ticked box, and "unpaid, $40k a year" is not a listing
|
|
641
|
+
// anybody can act on.
|
|
642
|
+
return {
|
|
643
|
+
lines: saidUnpaid ? [] : lines,
|
|
644
|
+
method,
|
|
645
|
+
equity,
|
|
646
|
+
unpaid: saidUnpaid,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* The pay of any listing, including one from a board that predates `pay`.
|
|
651
|
+
*
|
|
652
|
+
* Federated search puts listings from other instances on this board's pages,
|
|
653
|
+
* and an instance still on 0.11 serves a `salary` and no `pay`. Reading
|
|
654
|
+
* `job.pay` straight would throw on those, so every display path goes
|
|
655
|
+
* through here.
|
|
656
|
+
*/
|
|
657
|
+
export function payOfJob(job) {
|
|
658
|
+
if (job.pay !== undefined && job.pay !== null && Array.isArray(job.pay.lines))
|
|
659
|
+
return job.pay;
|
|
660
|
+
if (job.salary !== undefined && job.salary !== null)
|
|
661
|
+
return payFromSalary(job.salary);
|
|
662
|
+
return EMPTY_PAY;
|
|
663
|
+
}
|
|
664
|
+
/** The lines as the text a person would type, one per line. Round-trips through parsePayLine. */
|
|
665
|
+
export function payToText(pay) {
|
|
666
|
+
return pay.lines.map(formatPayLine).join('\n');
|
|
667
|
+
}
|
|
668
|
+
//# sourceMappingURL=pay.js.map
|