jobless 1.4.0 → 1.6.0

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/src/screens.js CHANGED
@@ -1,20 +1,21 @@
1
- import { execFileSync } from 'node:child_process';
2
-
3
- import { printCompanyReject } from './reject.js';
4
-
1
+ import { copyToClipboard } from './clip.js';
2
+ import { tweetFromReport } from './fit.js';
3
+ import { letterText } from './reject.js';
4
+ export * from './profile-screens.js';
5
+ export * from './lint-screens.js';
5
6
  import {
6
- about,
7
+ cases,
8
+ claims,
7
9
  experience,
8
10
  hireMailto,
9
11
  lookingFor,
10
12
  menu,
13
+ mentorship,
11
14
  profile,
12
- quotes,
13
15
  rants,
14
16
  signature,
15
17
  skills,
16
18
  stats,
17
- work,
18
19
  } from './data.js';
19
20
  import {
20
21
  banner,
@@ -26,106 +27,26 @@ import {
26
27
  dim,
27
28
  green,
28
29
  inner,
29
- kv,
30
- link,
31
- magenta,
32
30
  pad,
33
31
  print,
34
- red,
35
32
  section,
36
33
  wrap,
37
34
  yellow,
38
35
  } from './style.js';
39
36
 
40
- const copyToClipboard = (text) => {
41
- try {
42
- if (process.platform === 'darwin') {
43
- execFileSync('pbcopy', { input: text });
44
- return true;
45
- }
46
- if (process.platform === 'win32') {
47
- execFileSync('clip', { input: text, windowsHide: true });
48
- return true;
49
- }
50
- execFileSync('xclip', ['-selection', 'clipboard'], { input: text });
51
- return true;
52
- } catch {
53
- return false;
54
- }
55
- };
56
-
57
- export const printHeader = () => {
58
- print(banner());
59
- blank();
60
- print(` ${bold(cyan(profile.name))}`);
61
- print(` ${profile.title}`);
62
- print(` ${dim(`${profile.location} · ${profile.availability}`)}`);
63
- blank();
64
- const statLine = (items) =>
65
- items.map((item) => `${bold(green(item.value))} ${dim(item.label)}`).join(dim(' '));
66
- print(` ${statLine(stats.slice(0, 2))}`);
67
- print(` ${statLine(stats.slice(2))}`);
68
- blank();
69
- print(
70
- ` ${link(profile.email, hireMailto)}${dim(' · ')}${link(profile.website, profile.website)}${dim(' · ')}${link(
71
- profile.github.replace('https://', ''),
72
- profile.github,
73
- )}`,
74
- );
75
- };
76
-
77
- export const printSignature = () => {
78
- blank();
79
- section('PROOF');
80
- blank();
81
- print(` ${bold(signature.title)}`);
82
- blank();
83
- for (const line of signature.lines) bullet(line);
84
- };
85
-
86
37
  export const printHints = () => {
87
38
  blank();
88
39
  print(
89
- ` ${bold(green('h'))} hire ${dim('·')} ${bold('1')} proof ${dim('·')} ${bold('l')} lint a posting ${dim('·')} ${bold('q')} quit`,
40
+ ` ${bold(green('h'))} hire ${dim('·')} ${bold('1')} proof ${dim('·')} ${bold('l')} lint a posting ${dim('·')} ${bold('c')} copy letter ${dim('·')} ${bold('q')} quit`,
90
41
  );
91
42
  };
92
43
 
93
- export const printWho = () => {
94
- blank();
95
- section('WHO');
96
- blank();
97
- for (const paragraph of about) {
98
- bullet(paragraph);
99
- blank();
100
- }
101
- kv('Now', profile.currently);
102
- kv('Study', profile.study);
103
- kv('Site', link(profile.website, profile.website));
104
- };
105
-
106
- export const printLooking = () => {
107
- blank();
108
- section('WHAT I WANT');
109
- blank();
110
- print(` ${dim('roles')}`);
111
- for (const role of lookingFor.roles) bullet(role, green);
112
- blank();
113
- print(` ${dim('setup')}`);
114
- for (const item of lookingFor.setup) bullet(item);
115
- blank();
116
- print(` ${dim('yes')}`);
117
- for (const item of lookingFor.wants) bullet(item);
118
- blank();
119
- print(` ${dim('hard no')}`);
120
- for (const item of lookingFor.no) bullet(item, yellow);
121
- };
122
-
123
44
  export const printRant = () => {
124
45
  blank();
125
46
  section('THE MARKET');
126
47
  blank();
127
48
  for (const line of wrap(
128
- 'The feed is thriving. Hiring is a lore page. The recruiter\'s coffee order is a senior requirement.',
49
+ 'A hiring incident report, with coping mechanisms.',
129
50
  )) {
130
51
  print(` ${dim(line)}`);
131
52
  }
@@ -138,96 +59,29 @@ export const printRant = () => {
138
59
  print(` ${dim('If you want the work, press')} ${bold('1')}${dim('. If you want to hire, press')} ${bold(green('h'))}${dim('.')}`);
139
60
  };
140
61
 
141
- export const printLintReport = (report) => {
142
- const { findings, source, errors, warnings, letter, ok } = report;
143
-
144
- blank();
145
- section('JOBLESS LINT');
146
- blank();
147
- print(` ${dim(source)}`);
148
- blank();
149
-
150
- if (ok && !findings.length) {
151
- print(` ${green('clean')} this posting might be a real job.`);
152
- blank();
153
- print(` ${dim('If you still want the engineer who wrote the linter:')} ${bold(green('h'))}`);
154
- return report;
155
- }
156
-
157
- for (const finding of findings) {
158
- const tag = finding.level === 'error' ? red('error') : yellow('warn ');
159
- print(` ${tag} ${finding.message}`);
160
- print(` ${dim(finding.id)}`);
161
- blank();
162
- }
163
-
164
- printCompanyReject(letter);
165
- blank();
166
- print(
167
- ` ${errors} error${errors === 1 ? '' : 's'} ${warnings} warning${warnings === 1 ? '' : 's'} ${dim('·')} ${dim('hire the person who wrote the compiler:')} ${bold(green('h'))}`,
168
- );
169
- return report;
170
- };
171
-
172
- export const printWork = () => {
173
- printSignature();
62
+ export const printTweet = (report) => {
174
63
  blank();
175
- print(` ${dim('experience')}`);
64
+ print(tweetFromReport(report));
176
65
  blank();
177
- for (const job of experience) {
178
- print(` ${bold(job.role)}`);
179
- print(` ${cyan(job.company)} ${dim(`${job.dates} · ${job.place}`)}`);
180
- blank();
181
- for (const point of job.points) bullet(point);
182
- print(` ${dim(job.stack.join(' · '))}`);
183
- blank();
184
- }
185
- print(` ${dim('selected work')}`);
186
- blank();
187
- for (const item of work) {
188
- print(` ${bold(item.title)} ${dim(item.company)}`);
189
- for (const line of wrap(item.blurb)) print(` ${line}`);
190
- blank();
191
- }
192
- print(` ${dim('people who were not bots')}`);
193
- blank();
194
- for (const quote of quotes) {
195
- print(` ${magenta(`"${quote.text}"`)}`);
196
- print(` ${dim(`— ${quote.name}${quote.role ? `, ${quote.role}` : ''}`)}`);
197
- blank();
198
- }
199
- };
200
-
201
- export const printSkills = () => {
202
- blank();
203
- section('STACK');
204
- blank();
205
- for (const group of skills) {
206
- print(` ${bold(group.name)}`);
207
- print(` ${group.items.join(dim(' · '))}`);
208
- blank();
209
- }
66
+ print(` ${dim('Copy it, remix it, send it to someone waiting for a reply.')}`);
210
67
  };
211
68
 
212
- export const printHire = ({ copy = true } = {}) => {
69
+ export const printWhy = () => {
213
70
  blank();
214
- section('HIRE ME');
71
+ section('WHY');
215
72
  blank();
216
- print(` ${dim('Skip the 47-field form. This is the whole application.')}`);
217
- blank();
218
- kv('Email', link(profile.email, hireMailto));
219
- kv('Phone', link(profile.phone, `tel:${profile.phone.replace(/\s+/g, '')}`));
220
- kv('Site', link(profile.website, profile.website));
221
- kv('GitHub', link(profile.github, profile.github));
222
- kv('LinkedIn', link(profile.linkedin, profile.linkedin));
223
- kv('Resume', link(profile.resume, profile.resume));
224
- blank();
225
- if (copy && process.stdout.isTTY && copyToClipboard(profile.email)) {
226
- print(` ${green('copied')} ${profile.email} ${dim('to clipboard')}`);
73
+ for (const line of [
74
+ 'I got invited to interview and rejected at the same time.',
75
+ 'So I turned the hiring process into an npm package.',
76
+ 'They asked how I handle rejection. Apparently: semantic versioning.',
77
+ 'The second act is a reverse ATS: lint the posting, then reject it yourself.',
78
+ 'The hiring simulator is satire. The engineer behind it is real.',
79
+ 'Try npx jobless lint --demo. Meet me with npx jobless hire.',
80
+ ]) {
81
+ bullet(line);
227
82
  }
228
- print(` ${dim('or')} ${link('open a prefilled email', hireMailto)}`);
229
83
  blank();
230
- print(` ${dim('Remote senior / full-stack. I can start a conversation this week.')}`);
84
+ print(` ${dim('Hire is')} ${bold(green('h'))}${dim('.')}`);
231
85
  };
232
86
 
233
87
  export const printMenu = () => {
@@ -245,25 +99,49 @@ export const printMenu = () => {
245
99
  export const printHelp = () => {
246
100
  print(banner());
247
101
  blank();
248
- print(` ${bold('npx jobless')} reject you, then the resume`);
249
- print(` ${bold('npx jobless lint [file]')} lint a job posting`);
102
+ print(` ${bold('npx jobless')} reproduce a hiring race condition`);
103
+ print(` ${bold('npx jobless share --copy')} copy a compact anonymous result`);
104
+ print(` ${bold('npx jobless --record')} repeatable, anonymous, no prompt`);
105
+ print(` ${bold('npx jobless --personalize')} opt in to your local Git name`);
106
+ print(` ${bold('npx jobless lint --demo')} reject a fictional posting`);
107
+ print(` ${bold('npx jobless lint [file]')} lint a job posting or jobs/`);
250
108
  print(` ${bold('npx jobless lint --json')} findings + rejection letter`);
251
- print(` ${bold('npx jobless work')} proof of work`);
109
+ print(` ${bold('npx jobless lint --fix')} unified diff (dry-run)`);
110
+ print(` ${bold('npx jobless lint --write')} write the rewrite to the file`);
111
+ print(` ${bold('npx jobless lint --copy')} copy the company letter`);
112
+ print(` ${bold('npx jobless lint --rules')} print the rule table`);
113
+ print(` ${bold('npx jobless lint --format=github')} CI annotations`);
114
+ print(` ${bold('npx jobless work')} three case studies + timeline`);
115
+ print(` ${bold('npx jobless proof')} the 17-cron write-up`);
116
+ print(` ${bold('npx jobless card')} one-screen hire card`);
252
117
  print(` ${bold('npx jobless hire')} email + links`);
118
+ print(` ${bold('npx jobless hire --to=manager')} hiring-manager subject`);
119
+ print(` ${bold('npx jobless vcf')} vCard`);
120
+ print(` ${bold('npx jobless why')} why this exists`);
121
+ print(` ${bold('npx jobless tweet')} the origin story, ready to share`);
253
122
  print(` ${bold('npx jobless who')} bio`);
254
123
  print(` ${bold('npx jobless looking')} roles`);
255
124
  print(` ${bold('npx jobless skills')} stack`);
256
125
  print(` ${bold('npx jobless rant')} the market`);
257
126
  print(` ${bold('npx jobless --resume')} skip the rejection`);
127
+ print(` ${bold('npx jobless --seed=42')} choose a repeatable scenario`);
258
128
  print(` ${bold('npx jobless --json')} machine readable resume`);
129
+ print(` ${bold('npx jobless --no-color')} plain text output`);
259
130
  blank();
260
- print(` ${dim('In the prompt:')} ${bold(green('h'))} ${dim('hires,')} ${bold('l')} ${dim('lints a posting,')} ${bold('q')} ${dim('quits.')}`);
131
+ print(` ${dim('In the prompt:')} ${bold(green('h'))} ${dim('hires,')} ${bold('l')} ${dim('lints,')} ${bold('c')} ${dim('copies the last result,')} ${bold('q')} ${dim('quits.')}`);
132
+ print(` ${dim('Post the command, not the punchline:')} ${bold('npx jobless')}`);
261
133
  print(` ${dim(profile.website)}`);
262
134
  };
263
135
 
264
136
  export const printLandingTeaser = () => {
265
137
  blank();
266
- print(` ${dim('Paste a posting with')} ${bold('l')}${dim('. The rant is')} ${bold('5')}${dim('.')}`);
138
+ print(` ${dim('Paste the posting that just rejected you:')} ${bold('l')}${dim('.')}`);
139
+ };
140
+
141
+ export const copyLetter = (letter) => {
142
+ const text = letterText(letter);
143
+ if (!text) return false;
144
+ return copyToClipboard(text);
267
145
  };
268
146
 
269
147
  export const toJson = () =>
@@ -286,6 +164,9 @@ export const toJson = () =>
286
164
  lookingFor,
287
165
  rants,
288
166
  stats,
167
+ claims,
168
+ cases,
169
+ mentorship,
289
170
  experience,
290
171
  skills,
291
172
  },
package/src/style.js CHANGED
@@ -31,7 +31,7 @@ export const brightRed = paint('91');
31
31
 
32
32
  export const width = () => {
33
33
  const columns = stdout.columns || 80;
34
- return Math.min(Math.max(columns, 56), 86);
34
+ return Math.min(Math.max(columns, 10), 86);
35
35
  };
36
36
 
37
37
  export const inner = () => width() - 4;
@@ -46,7 +46,12 @@ export const wrap = (text, max = width() - 8) => {
46
46
  continue;
47
47
  }
48
48
 
49
- const words = paragraph.split(/\s+/);
49
+ const words = paragraph.split(/\s+/).flatMap((word) => {
50
+ const chars = Array.from(word);
51
+ const chunks = [];
52
+ for (let i = 0; i < chars.length; i += max) chunks.push(chars.slice(i, i + max).join(''));
53
+ return chunks;
54
+ });
50
55
  let line = '';
51
56
 
52
57
  for (const word of words) {
@@ -87,11 +92,13 @@ export const clear = () => {
87
92
  if (stdout.isTTY) stdout.write(`${CSI}2J${CSI}H`);
88
93
  };
89
94
 
90
- const visibleLength = (text) =>
95
+ const plainText = (text) =>
91
96
  String(text)
92
97
  .replace(/\x1b\]8;;[^\x07]*\x07/g, '')
93
98
  .replace(/\x1b\]8;;\x07/g, '')
94
- .replace(/\x1b\[[0-9;]*m/g, '').length;
99
+ .replace(/\x1b\[[0-9;]*m/g, '');
100
+
101
+ const visibleLength = (text) => Array.from(plainText(text)).length;
95
102
 
96
103
  export const pad = (text, size = inner(), align = 'left') => {
97
104
  const length = visibleLength(text);
@@ -106,10 +113,11 @@ export const pad = (text, size = inner(), align = 'left') => {
106
113
 
107
114
  export const box = (lines, { title } = {}) => {
108
115
  const max = inner();
109
- const topInner = title ? `─ ${title} ${'─'.repeat(Math.max(max - title.length - 3, 0))}` : '─'.repeat(max);
110
- const top = dim(`┌${topInner}┐`);
111
- const bottom = dim(`└${'─'.repeat(max)}┘`);
112
- const body = lines.map((line) => `${dim('│')} ${pad(line, max)} ${dim('│')}`);
116
+ const label = title ? ` ${title} `.slice(0, max) : '';
117
+ const top = dim(`┌─${label}${'─'.repeat(max - label.length + 1)}┐`);
118
+ const bottom = dim(`└${'─'.repeat(max + 2)}┘`);
119
+ const rows = lines.flatMap((line) => visibleLength(line) > max ? wrap(plainText(line), max) : [line]);
120
+ const body = rows.map((line) => `${dim('│')} ${pad(line, max)} ${dim('│')}`);
113
121
  return [top, ...body, bottom].join('\n');
114
122
  };
115
123
 
@@ -0,0 +1,152 @@
1
+ export const experience = [
2
+ {
3
+ role: 'Senior Full Stack Engineer',
4
+ company: 'TPK Synergy Limited',
5
+ dates: 'Dec 2025 – Present',
6
+ place: 'Remote',
7
+ points: [
8
+ 'Amazon seller analytics: marketplace analytics, FBA, finance, PPC, and inventory.',
9
+ 'Diagnosed sync jobs that died after every deploy; shutdown handling across 17 cron processes. See `proof`.',
10
+ 'Amazon SP-API and Ads auth end to end: OAuth, LWA, encrypted credentials.',
11
+ 'UK and EU marketplaces with VAT-aware margins. Moved expensive aggregations into SQL.',
12
+ ],
13
+ stack: ['Node.js', 'PostgreSQL', 'BullMQ', 'Redis', 'React', 'Amazon SP-API'],
14
+ },
15
+ {
16
+ role: 'Senior Software Engineer',
17
+ company: 'Mereb Technologies',
18
+ dates: 'Apr 2022 – Nov 2025',
19
+ place: 'Addis Ababa',
20
+ points: [
21
+ 'Owned the production gaming platform end to end in these domains: loyalty, payouts, fraud, reporting — not the whole company.',
22
+ '~30% platform performance: indexing, read/write separation, caching, pagination, redesigned status-checking.',
23
+ '~35% infra cost, about $100K a year: database, cache, and resource optimization. Engineering estimate.',
24
+ 'Reports on ~1M-row datasets: 2 hours to under 30 minutes. Mentored 3 juniors on APIs, debugging, and design.',
25
+ ],
26
+ stack: ['PostgreSQL', 'gRPC', 'PHP', 'React', 'AWS', 'Datadog'],
27
+ },
28
+ {
29
+ role: 'Full Stack Developer (Contract)',
30
+ company: 'Affiliate.com',
31
+ dates: 'Feb 2023 – Aug 2023',
32
+ place: 'Remote · alongside Mereb',
33
+ points: [
34
+ 'Migrated a legacy PHP API to Node.js and tRPC without breaking the React UI.',
35
+ 'Reimplemented legacy SQL in Prisma while both platforms ran in parallel.',
36
+ 'Cut report generation ~40% by moving it onto Google Cloud Storage. ~85% coverage on migrated code.',
37
+ ],
38
+ stack: ['Node.js', 'tRPC', 'Prisma', 'React', 'Zod', 'GCS'],
39
+ },
40
+ {
41
+ role: 'Backend Engineer (Contract)',
42
+ company: 'Autobridge Systems',
43
+ dates: 'Contract',
44
+ place: 'Addis Ababa · government constituent platform',
45
+ points: [
46
+ 'Backend for complaints, document approvals, and internal communication, with access control and encryption.',
47
+ 'Traced multi-minute email-record loads to missing indexes. Those operations improved more than 10x.',
48
+ ],
49
+ stack: ['PostgreSQL', 'Node.js', 'Encryption'],
50
+ },
51
+ {
52
+ role: 'Frontend Developer',
53
+ company: 'Eaglelion Systems Technology',
54
+ dates: 'Feb 2021 – Apr 2022',
55
+ place: 'Addis Ababa',
56
+ points: [
57
+ 'Client payment workflows on products with 100K+ downloads that have handled 3B+ ETB. I integrated the rails, not the ledger.',
58
+ 'Mastercard, Visa, Telebirr, bank APIs: states, callbacks, failures, retries, verification. SOAP→REST against incomplete docs.',
59
+ 'GTmetrix 45 → 97. Page load ~25% faster: code splitting, lazy loading, assets.',
60
+ ],
61
+ stack: ['React', 'Next.js', 'Tailwind', 'Payment APIs'],
62
+ },
63
+ ];
64
+
65
+ export const cases = [
66
+ {
67
+ id: 'crons',
68
+ title: 'The deploy that killed 17 cron jobs',
69
+ company: 'TPK Synergy Limited',
70
+ role: 'Senior Full Stack Engineer',
71
+ kind: 'Reliability · private production',
72
+ problem:
73
+ 'After every deploy, recurring Amazon sync jobs died quietly. Dashboards looked fine until the data was a day old.',
74
+ trace: [
75
+ 'BullMQ job state — work marked active, then gone',
76
+ 'ioredis connections closing with the Node process',
77
+ 'Node shutdown: no drain, no SIGTERM handler that waited',
78
+ 'Railway scheduler: the next tick assumed the last run had finished',
79
+ ],
80
+ change: 'Explicit shutdown handling across 17 cron processes so a deploy could not leave work stranded.',
81
+ result: 'Sync jobs survive deploys instead of going silent until a seller notices bad numbers.',
82
+ still:
83
+ 'A worker that exits without draining still looks healthy on the dashboard. The failure mode is silence, not a page. That is what I watch for.',
84
+ undo: 'I would put a queue-depth and last-success timestamp on the dashboard the same week. Shutdown handling without a visible lag is how the next silent failure hides.',
85
+ ask: [
86
+ 'What did SIGTERM actually do before the patch?',
87
+ 'How did you know it was 17 processes, not one flaky job?',
88
+ 'What still pages you, and what still does not?',
89
+ ],
90
+ stack: ['Node.js', 'BullMQ', 'Redis', 'Railway'],
91
+ },
92
+ {
93
+ id: 'reports',
94
+ title: 'Reports that used to take two hours',
95
+ company: 'Mereb Technologies',
96
+ role: 'Senior Software Engineer',
97
+ kind: 'Performance · private production',
98
+ problem: 'Reporting on about a million records ran for two hours and blocked people who needed the numbers.',
99
+ trace: [
100
+ 'Ad-hoc application paths doing aggregation the database should do',
101
+ 'Missing indexes, no read replica, cache that did not cover the hot report',
102
+ 'Status-checking that polled work already in flight',
103
+ ],
104
+ change:
105
+ 'Indexing, read/write separation, caching, and a redesigned status-checking flow so the database did the aggregation.',
106
+ result:
107
+ 'Same reports, under 30 minutes. Platform performance up about 30%. Infra cost down about 35% (~$100K/year, engineering estimate).',
108
+ still: 'I cannot show the queries here. They are in a private repo. I will walk through them on a call.',
109
+ undo: 'I would have split the hottest report into an async job on day one instead of waiting until it was a two-hour meeting blocker.',
110
+ ask: [
111
+ 'Which index moved the needle, and how did you measure it?',
112
+ 'What did you refuse to cache?',
113
+ 'Where does the 30% / $100K estimate come from?',
114
+ ],
115
+ stack: ['PostgreSQL', 'PHP', 'Redis', 'AWS'],
116
+ },
117
+ {
118
+ id: 'nedaj',
119
+ title: 'Nedaj payments',
120
+ company: 'Eaglelion Systems Technology',
121
+ role: 'Frontend Developer',
122
+ kind: 'Shipped product · client rails',
123
+ problem:
124
+ 'A fuel-purchase app had to talk to Mastercard, Visa, Telebirr, and commercial banks, with incomplete provider docs and a SOAP past.',
125
+ trace: [
126
+ 'I built the client payment workflows: states, callbacks, failures, retries, verification',
127
+ 'Not the acquiring bank, not the ledger, not the 3B ETB as a personal metric — the products handled that volume',
128
+ 'SOAP replaced with REST against docs that did not match production',
129
+ ],
130
+ change: 'Reliable payment states on a product with 100K+ downloads. Live on Google Play.',
131
+ result: 'Payment rails that hold up in production. GTmetrix 45 → 97 on the web surface.',
132
+ still: 'Provider callbacks still fail in ways the docs do not mention. Retries and verification are the job.',
133
+ undo: 'I would have treated provider docs as hostile from the first week and recorded every callback mismatch in one log, not in Slack.',
134
+ ask: [
135
+ 'What did you own on the client, and what did you not own?',
136
+ 'How did a failed Telebirr callback become a user-visible state?',
137
+ 'Why is 3B ETB not your personal metric?',
138
+ ],
139
+ stack: ['React', 'Next.js', 'REST', 'Payment APIs'],
140
+ url: 'https://play.google.com/store/apps/details?id=com.eaglelionsystems.nedaj',
141
+ },
142
+ ];
143
+
144
+ export const work = cases;
145
+
146
+ export const mentorship = {
147
+ where: 'Mereb Technologies',
148
+ who: '3 junior engineers',
149
+ on: ['API development', 'debugging', 'code quality', 'system design'],
150
+ still: 'They can speak to the reviews. I will not invent a quote for them. Ask me who, and I will name the work.',
151
+ };
152
+