flowcvcli 0.2.0__tar.gz → 0.5.2__tar.gz
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.
- {flowcvcli-0.2.0/flowcvcli.egg-info → flowcvcli-0.5.2}/PKG-INFO +22 -7
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/README.md +21 -6
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/__init__.py +1 -1
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/cli.py +82 -12
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/content.py +50 -10
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/markup.py +11 -2
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/resume.py +21 -8
- {flowcvcli-0.2.0 → flowcvcli-0.5.2/flowcvcli.egg-info}/PKG-INFO +22 -7
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/.env.example +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/LICENSE +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/MANIFEST.in +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/docs/API.md +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/docs/RENDERING.md +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/__main__.py +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/api.py +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/client.py +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/config.py +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/customization.py +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/personal.py +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/photo.py +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli.egg-info/SOURCES.txt +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli.egg-info/dependency_links.txt +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli.egg-info/entry_points.txt +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli.egg-info/top_level.txt +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/pyproject.toml +0 -0
- {flowcvcli-0.2.0 → flowcvcli-0.5.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flowcvcli
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Control a FlowCV resume from the command line or Python — content, design, templates, photo, publish and PDF export — via FlowCV's private JSON API. Zero dependencies.
|
|
5
5
|
Author: dannyota
|
|
6
6
|
License-Expression: MIT
|
|
@@ -31,12 +31,12 @@ Dynamic: license-file
|
|
|
31
31
|
# flowcvcli
|
|
32
32
|
|
|
33
33
|
[](https://pypi.org/project/flowcvcli/)
|
|
34
|
-
[](https://pypi.org/project/flowcvcli/)
|
|
35
35
|
[](LICENSE)
|
|
36
36
|
|
|
37
37
|
Control a [FlowCV](https://flowcv.com) resume from the **command line** or from
|
|
38
38
|
**Python** — content, header & links, **customization**, **templates**,
|
|
39
|
-
**avatar**, reorder/hide, multi-resume management, publish, and **PDF export**.
|
|
39
|
+
**avatar**, reorder/hide, multi-resume management, **backup/restore**, publish, and **PDF export**.
|
|
40
40
|
It drives FlowCV's private JSON API (the same calls the web app makes), so it
|
|
41
41
|
works for any FlowCV resume with your own session. **Zero dependencies** (Python
|
|
42
42
|
standard library only), so it's easy to drop into scripts and LLM agents.
|
|
@@ -96,10 +96,13 @@ flowcv duplicate ["Copy title"] # full copy of the current resume
|
|
|
96
96
|
flowcv rename "New Title" # rename the current resume
|
|
97
97
|
flowcv delete-resume --yes # permanent (refuses without --yes)
|
|
98
98
|
|
|
99
|
-
# content (markdown mini-format below); `add` creates the section if needed
|
|
99
|
+
# content (markdown mini-format below); `add` creates the section if needed.
|
|
100
|
+
# --set aliases (title/company/link) resolve per section (work->jobTitle, publication->title…)
|
|
100
101
|
flowcv add work --set title="Engineer" --set company="Acme" \
|
|
101
102
|
--set start=01/2022 --set end=Present --text $'- Did a measurable thing.'
|
|
102
|
-
flowcv
|
|
103
|
+
flowcv add custom2 --section-name "Open Source" --icon code --text "…" # heading+icon at creation
|
|
104
|
+
flowcv desc work <id> --file role.md # rich text; --field is optional (auto: profile=text, skill=infoHtml)
|
|
105
|
+
flowcv date publication <id> --year 2018 # structured date (year-only by default)
|
|
103
106
|
flowcv field work <id> employer --text "Acme Corp"
|
|
104
107
|
flowcv rm work <id>
|
|
105
108
|
|
|
@@ -109,7 +112,7 @@ flowcv hide work <id> ; flowcv show-entry work <id>
|
|
|
109
112
|
flowcv rename-section skill "Core Skills"
|
|
110
113
|
flowcv section-icon skill head-side-brain
|
|
111
114
|
flowcv rm-section custom1 --yes # delete a section + its entries
|
|
112
|
-
flowcv reorder-sections profile work skill education # one-column order
|
|
115
|
+
flowcv reorder-sections profile work skill education # set one-column order (no args = print current)
|
|
113
116
|
|
|
114
117
|
# header details & links (links are social entries: orcid, googlescholar, github…)
|
|
115
118
|
flowcv pd jobTitle --text "Security Leader"
|
|
@@ -131,6 +134,10 @@ flowcv download -o resume.pdf # the rendered PDF
|
|
|
131
134
|
flowcv download --token <webToken> -o out.pdf # any PUBLIC resume by its share token (no auth)
|
|
132
135
|
flowcv share | publish | unpublish
|
|
133
136
|
|
|
137
|
+
# backup / restore
|
|
138
|
+
flowcv export -o backup.json # full resume snapshot (JSON) — keep one before big edits
|
|
139
|
+
flowcv import backup.json # restore the snapshot into a NEW resume (non-destructive)
|
|
140
|
+
|
|
134
141
|
flowcv login # refresh the cached session
|
|
135
142
|
flowcv md2html --file role.md # preview HTML (offline)
|
|
136
143
|
```
|
|
@@ -159,6 +166,12 @@ fc.rename_section("skill", "Core Skills"); fc.delete_section("custom1")
|
|
|
159
166
|
fc.hide_entry("work", "id", hidden=True)
|
|
160
167
|
new_id = fc.create_resume("Second Resume") # or fc.duplicate_resume()
|
|
161
168
|
fc.rename_resume("New Title"); fc.delete_resume() # delete is permanent
|
|
169
|
+
fc.set_date("publication", "id", year=2018) # structured date (year-only)
|
|
170
|
+
|
|
171
|
+
# backup / restore
|
|
172
|
+
import json
|
|
173
|
+
json.dump(fc.export_resume(), open("backup.json", "w")) # full snapshot
|
|
174
|
+
new_id = fc.import_resume(json.load(open("backup.json"))) # restore into a NEW resume
|
|
162
175
|
```
|
|
163
176
|
|
|
164
177
|
### Build → render → check → improve
|
|
@@ -173,9 +186,11 @@ feedback loop for building a resume from raw info.
|
|
|
173
186
|
|---|---|
|
|
174
187
|
| blank line | block separator |
|
|
175
188
|
| `## Heading` / `**Whole line bold**` | bold subheader |
|
|
189
|
+
| `***Whole line***` | bold + italic subheader |
|
|
176
190
|
| `- item` | bullet (consecutive = one list) |
|
|
177
191
|
| anything else | justified paragraph |
|
|
178
|
-
| `**bold**` inline | `<strong
|
|
192
|
+
| `**bold**` / `***bold-italic***` inline | `<strong>` / `<strong><em>…</em></strong>` |
|
|
193
|
+
| `[text](url)` inline | link |
|
|
179
194
|
|
|
180
195
|
## How it works
|
|
181
196
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# flowcvcli
|
|
2
2
|
|
|
3
3
|
[](https://pypi.org/project/flowcvcli/)
|
|
4
|
-
[](https://pypi.org/project/flowcvcli/)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
7
|
Control a [FlowCV](https://flowcv.com) resume from the **command line** or from
|
|
8
8
|
**Python** — content, header & links, **customization**, **templates**,
|
|
9
|
-
**avatar**, reorder/hide, multi-resume management, publish, and **PDF export**.
|
|
9
|
+
**avatar**, reorder/hide, multi-resume management, **backup/restore**, publish, and **PDF export**.
|
|
10
10
|
It drives FlowCV's private JSON API (the same calls the web app makes), so it
|
|
11
11
|
works for any FlowCV resume with your own session. **Zero dependencies** (Python
|
|
12
12
|
standard library only), so it's easy to drop into scripts and LLM agents.
|
|
@@ -66,10 +66,13 @@ flowcv duplicate ["Copy title"] # full copy of the current resume
|
|
|
66
66
|
flowcv rename "New Title" # rename the current resume
|
|
67
67
|
flowcv delete-resume --yes # permanent (refuses without --yes)
|
|
68
68
|
|
|
69
|
-
# content (markdown mini-format below); `add` creates the section if needed
|
|
69
|
+
# content (markdown mini-format below); `add` creates the section if needed.
|
|
70
|
+
# --set aliases (title/company/link) resolve per section (work->jobTitle, publication->title…)
|
|
70
71
|
flowcv add work --set title="Engineer" --set company="Acme" \
|
|
71
72
|
--set start=01/2022 --set end=Present --text $'- Did a measurable thing.'
|
|
72
|
-
flowcv
|
|
73
|
+
flowcv add custom2 --section-name "Open Source" --icon code --text "…" # heading+icon at creation
|
|
74
|
+
flowcv desc work <id> --file role.md # rich text; --field is optional (auto: profile=text, skill=infoHtml)
|
|
75
|
+
flowcv date publication <id> --year 2018 # structured date (year-only by default)
|
|
73
76
|
flowcv field work <id> employer --text "Acme Corp"
|
|
74
77
|
flowcv rm work <id>
|
|
75
78
|
|
|
@@ -79,7 +82,7 @@ flowcv hide work <id> ; flowcv show-entry work <id>
|
|
|
79
82
|
flowcv rename-section skill "Core Skills"
|
|
80
83
|
flowcv section-icon skill head-side-brain
|
|
81
84
|
flowcv rm-section custom1 --yes # delete a section + its entries
|
|
82
|
-
flowcv reorder-sections profile work skill education # one-column order
|
|
85
|
+
flowcv reorder-sections profile work skill education # set one-column order (no args = print current)
|
|
83
86
|
|
|
84
87
|
# header details & links (links are social entries: orcid, googlescholar, github…)
|
|
85
88
|
flowcv pd jobTitle --text "Security Leader"
|
|
@@ -101,6 +104,10 @@ flowcv download -o resume.pdf # the rendered PDF
|
|
|
101
104
|
flowcv download --token <webToken> -o out.pdf # any PUBLIC resume by its share token (no auth)
|
|
102
105
|
flowcv share | publish | unpublish
|
|
103
106
|
|
|
107
|
+
# backup / restore
|
|
108
|
+
flowcv export -o backup.json # full resume snapshot (JSON) — keep one before big edits
|
|
109
|
+
flowcv import backup.json # restore the snapshot into a NEW resume (non-destructive)
|
|
110
|
+
|
|
104
111
|
flowcv login # refresh the cached session
|
|
105
112
|
flowcv md2html --file role.md # preview HTML (offline)
|
|
106
113
|
```
|
|
@@ -129,6 +136,12 @@ fc.rename_section("skill", "Core Skills"); fc.delete_section("custom1")
|
|
|
129
136
|
fc.hide_entry("work", "id", hidden=True)
|
|
130
137
|
new_id = fc.create_resume("Second Resume") # or fc.duplicate_resume()
|
|
131
138
|
fc.rename_resume("New Title"); fc.delete_resume() # delete is permanent
|
|
139
|
+
fc.set_date("publication", "id", year=2018) # structured date (year-only)
|
|
140
|
+
|
|
141
|
+
# backup / restore
|
|
142
|
+
import json
|
|
143
|
+
json.dump(fc.export_resume(), open("backup.json", "w")) # full snapshot
|
|
144
|
+
new_id = fc.import_resume(json.load(open("backup.json"))) # restore into a NEW resume
|
|
132
145
|
```
|
|
133
146
|
|
|
134
147
|
### Build → render → check → improve
|
|
@@ -143,9 +156,11 @@ feedback loop for building a resume from raw info.
|
|
|
143
156
|
|---|---|
|
|
144
157
|
| blank line | block separator |
|
|
145
158
|
| `## Heading` / `**Whole line bold**` | bold subheader |
|
|
159
|
+
| `***Whole line***` | bold + italic subheader |
|
|
146
160
|
| `- item` | bullet (consecutive = one list) |
|
|
147
161
|
| anything else | justified paragraph |
|
|
148
|
-
| `**bold**` inline | `<strong
|
|
162
|
+
| `**bold**` / `***bold-italic***` inline | `<strong>` / `<strong><em>…</em></strong>` |
|
|
163
|
+
| `[text](url)` inline | link |
|
|
149
164
|
|
|
150
165
|
## How it works
|
|
151
166
|
|
|
@@ -8,18 +8,36 @@ or pass `--resume-id <id>` (any command accepts it).
|
|
|
8
8
|
import argparse
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
|
+
import re
|
|
11
12
|
import sys
|
|
12
13
|
|
|
13
14
|
from .api import FlowCV
|
|
14
15
|
from .client import login as do_login, _write_session, _jar_header
|
|
15
|
-
from .content import SECTION_META, label_of
|
|
16
|
+
from .content import SECTION_META, label_of, rich_field
|
|
16
17
|
from .markup import html_to_text, md_to_html
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
# `--set key=value` friendly aliases. `start`/`end` are common to all sections;
|
|
20
|
+
# `title`/`company`/`link` map to different real fields per section, so resolve
|
|
21
|
+
# them section-aware (the old flat map mis-set custom/publication entries).
|
|
22
|
+
_ALIASES_COMMON = {"start": "startDateNew", "end": "endDateNew"}
|
|
23
|
+
_ALIASES_BY_SECTION = {
|
|
24
|
+
"work": {"title": "jobTitle", "company": "employer", "link": "employerLink"},
|
|
25
|
+
"education": {"title": "degree", "company": "school", "link": "schoolLink"},
|
|
26
|
+
"publication": {"title": "title", "company": "publisher", "link": "titleLink"},
|
|
27
|
+
"organisation": {"title": "position", "company": "organisationName", "link": "organisationLink"},
|
|
28
|
+
"custom": {"title": "title", "link": "titleLink"},
|
|
29
|
+
}
|
|
20
30
|
TEXT_FIELDS = ("description", "infoHtml", "text")
|
|
21
31
|
|
|
22
32
|
|
|
33
|
+
def _resolve_set_key(section, key):
|
|
34
|
+
"""Map a friendly --set key to the real entry field for `section`."""
|
|
35
|
+
if key in _ALIASES_COMMON:
|
|
36
|
+
return _ALIASES_COMMON[key]
|
|
37
|
+
sec = "custom" if re.fullmatch(r"custom\d+", section or "") else section
|
|
38
|
+
return _ALIASES_BY_SECTION.get(sec, {}).get(key, key)
|
|
39
|
+
|
|
40
|
+
|
|
23
41
|
def _read(file=None, text=None):
|
|
24
42
|
if file:
|
|
25
43
|
with open(file) as f:
|
|
@@ -111,8 +129,9 @@ def cmd_add(a):
|
|
|
111
129
|
if "=" not in kv:
|
|
112
130
|
sys.exit(f"--set expects key=value, got {kv!r}")
|
|
113
131
|
k, _, v = kv.partition("=")
|
|
114
|
-
sets[
|
|
115
|
-
new_id = _fc(a).add_entry(a.section, sets=sets, md=_read(a.file, a.text)
|
|
132
|
+
sets[_resolve_set_key(a.section, k)] = v
|
|
133
|
+
new_id = _fc(a).add_entry(a.section, sets=sets, md=_read(a.file, a.text),
|
|
134
|
+
section_name=a.section_name, section_icon=a.icon)
|
|
116
135
|
print(f"added {a.section} entry -> {new_id}")
|
|
117
136
|
|
|
118
137
|
|
|
@@ -148,7 +167,24 @@ def cmd_rm_section(a):
|
|
|
148
167
|
|
|
149
168
|
|
|
150
169
|
def cmd_reorder_sections(a):
|
|
151
|
-
|
|
170
|
+
fc = _fc(a)
|
|
171
|
+
if not a.ids: # no ids -> print current order
|
|
172
|
+
resume = fc.get_resume()
|
|
173
|
+
so = (resume.get("customization") or {}).get("sectionOrder") or {}
|
|
174
|
+
content = resume.get("content") or {}
|
|
175
|
+
name = lambda sid: (content.get(sid) or {}).get("displayName", "")
|
|
176
|
+
print(f"section order ({a.layout}):")
|
|
177
|
+
if a.layout == "two":
|
|
178
|
+
lay = so.get("two") or {}
|
|
179
|
+
for side in ("leftSectionsSorted", "rightSectionsSorted"):
|
|
180
|
+
print(f" {side}:")
|
|
181
|
+
for sid in lay.get(side) or []:
|
|
182
|
+
print(f" {sid} {name(sid)}")
|
|
183
|
+
else:
|
|
184
|
+
for sid in (so.get(a.layout) or {}).get("sectionsSorted") or []:
|
|
185
|
+
print(f" {sid} {name(sid)}")
|
|
186
|
+
return
|
|
187
|
+
_result(fc.reorder_sections(a.ids, layout=a.layout), f"reorder-sections ({a.layout})")
|
|
152
188
|
|
|
153
189
|
|
|
154
190
|
def cmd_field(a):
|
|
@@ -157,8 +193,28 @@ def cmd_field(a):
|
|
|
157
193
|
|
|
158
194
|
|
|
159
195
|
def cmd_desc(a):
|
|
160
|
-
|
|
161
|
-
|
|
196
|
+
field = a.field or rich_field(a.section) # auto: profile->text, skill->infoHtml
|
|
197
|
+
_result(_fc(a).set_description(a.section, a.entry, _read(a.file, a.text), field=field),
|
|
198
|
+
f"{a.section}/{a.entry[:8]}.{field}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_date(a):
|
|
202
|
+
_result(_fc(a).set_date(a.section, a.entry, year=a.year, month=a.month, day=a.day),
|
|
203
|
+
f"{a.section}/{a.entry[:8]}.date")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_export(a):
|
|
207
|
+
out = a.output or "resume-backup.json"
|
|
208
|
+
with open(out, "w") as f:
|
|
209
|
+
json.dump(_fc(a).export_resume(), f, indent=2, ensure_ascii=False)
|
|
210
|
+
print(f"exported resume -> {out} ({os.path.getsize(out)} bytes)")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_import(a):
|
|
214
|
+
with open(a.file) as f:
|
|
215
|
+
data = json.load(f)
|
|
216
|
+
new_id = _fc(a).import_resume(data, title=a.title)
|
|
217
|
+
print(f"restored backup into a NEW resume -> {new_id} (current resume untouched)")
|
|
162
218
|
|
|
163
219
|
|
|
164
220
|
def cmd_pd(a):
|
|
@@ -271,7 +327,9 @@ def build_parser():
|
|
|
271
327
|
s = add("dump"); s.add_argument("section"); s.add_argument("entry"); s.set_defaults(fn=cmd_dump)
|
|
272
328
|
|
|
273
329
|
s = add("add"); s.add_argument("section")
|
|
274
|
-
s.add_argument("--set", action="append", help="field=value (aliases: title,company,start,end,link)")
|
|
330
|
+
s.add_argument("--set", action="append", help="field=value (section-aware aliases: title,company,start,end,link)")
|
|
331
|
+
s.add_argument("--section-name", help="display name to use if this creates a new section")
|
|
332
|
+
s.add_argument("--icon", help="icon key to use if this creates a new section, e.g. code")
|
|
275
333
|
g = s.add_mutually_exclusive_group(); g.add_argument("--file"); g.add_argument("--text")
|
|
276
334
|
s.set_defaults(fn=cmd_add)
|
|
277
335
|
|
|
@@ -288,8 +346,9 @@ def build_parser():
|
|
|
288
346
|
s = add("rm-section"); s.add_argument("section")
|
|
289
347
|
s.add_argument("--yes", action="store_true", help="confirm deleting the section + its entries")
|
|
290
348
|
s.set_defaults(fn=cmd_rm_section)
|
|
291
|
-
s = add("reorder-sections")
|
|
292
|
-
s.add_argument("
|
|
349
|
+
s = add("reorder-sections")
|
|
350
|
+
s.add_argument("ids", nargs="*", help="section ids in the desired order (omit to print the current order)")
|
|
351
|
+
s.add_argument("--layout", default="one", help="column layout (one|two|mix; default one)")
|
|
293
352
|
s.set_defaults(fn=cmd_reorder_sections)
|
|
294
353
|
|
|
295
354
|
s = add("field"); s.add_argument("section"); s.add_argument("entry"); s.add_argument("field")
|
|
@@ -297,10 +356,15 @@ def build_parser():
|
|
|
297
356
|
s.set_defaults(fn=cmd_field)
|
|
298
357
|
|
|
299
358
|
s = add("desc"); s.add_argument("section"); s.add_argument("entry")
|
|
300
|
-
s.add_argument("--field", default=
|
|
359
|
+
s.add_argument("--field", default=None,
|
|
360
|
+
help="rich-text field (default: auto by section — profile=text, skill=infoHtml, else description)")
|
|
301
361
|
g = s.add_mutually_exclusive_group(required=True); g.add_argument("--file"); g.add_argument("--text")
|
|
302
362
|
s.set_defaults(fn=cmd_desc)
|
|
303
363
|
|
|
364
|
+
s = add("date"); s.add_argument("section"); s.add_argument("entry")
|
|
365
|
+
s.add_argument("--year"); s.add_argument("--month"); s.add_argument("--day")
|
|
366
|
+
s.set_defaults(fn=cmd_date)
|
|
367
|
+
|
|
304
368
|
s = add("pd"); s.add_argument("field")
|
|
305
369
|
g = s.add_mutually_exclusive_group(required=True); g.add_argument("--text"); g.add_argument("--file")
|
|
306
370
|
s.set_defaults(fn=cmd_pd)
|
|
@@ -324,6 +388,12 @@ def build_parser():
|
|
|
324
388
|
add("unpublish").set_defaults(fn=cmd_unpublish)
|
|
325
389
|
add("share").set_defaults(fn=cmd_share)
|
|
326
390
|
|
|
391
|
+
s = add("export"); s.add_argument("-o", "--output", help="output JSON file (default resume-backup.json)")
|
|
392
|
+
s.set_defaults(fn=cmd_export)
|
|
393
|
+
s = add("import"); s.add_argument("file", help="a JSON backup produced by `export`")
|
|
394
|
+
s.add_argument("--title", help="title for the restored resume (default: '<name> (restored)')")
|
|
395
|
+
s.set_defaults(fn=cmd_import)
|
|
396
|
+
|
|
327
397
|
s = add("md2html")
|
|
328
398
|
g = s.add_mutually_exclusive_group(required=True); g.add_argument("--file"); g.add_argument("--text")
|
|
329
399
|
s.set_defaults(fn=cmd_md2html)
|
|
@@ -7,6 +7,7 @@ read-modify-write where the API replaces the whole entry; create uses
|
|
|
7
7
|
|
|
8
8
|
Mixes into Client; uses self.get_resume / self.request / self.resume_id.
|
|
9
9
|
"""
|
|
10
|
+
import re
|
|
10
11
|
import uuid
|
|
11
12
|
|
|
12
13
|
from .markup import md_to_html
|
|
@@ -22,6 +23,15 @@ SECTION_META = {
|
|
|
22
23
|
"custom1": ("custom", "Custom", "star"),
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
# Per-section rich-text field: most sections store rich text in `description`,
|
|
27
|
+
# but the summary uses `text` and skills use `infoHtml`.
|
|
28
|
+
RICH_TEXT_FIELD = {"profile": "text", "skill": "infoHtml"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rich_field(section):
|
|
32
|
+
"""The entry field that holds rich text for `section` (default 'description')."""
|
|
33
|
+
return RICH_TEXT_FIELD.get(section, "description")
|
|
34
|
+
|
|
25
35
|
|
|
26
36
|
def label_of(entry):
|
|
27
37
|
"""Best human label for an entry across section shapes."""
|
|
@@ -65,12 +75,14 @@ class ContentMixin:
|
|
|
65
75
|
return self.request("resumes/delete_entry", method="DELETE", query=query)
|
|
66
76
|
|
|
67
77
|
# ---- high-level helpers ----------------------------------------------
|
|
68
|
-
def add_entry(self, section, sets=None, md=None):
|
|
78
|
+
def add_entry(self, section, sets=None, md=None, section_name=None, section_icon=None):
|
|
69
79
|
"""Create an entry (and the section if needed); return the new id.
|
|
70
80
|
|
|
71
|
-
`sets` are entry fields to populate; `md` becomes
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
`sets` are entry fields to populate; `md` becomes the section's rich-text
|
|
82
|
+
field. Creates the entry first (so a missing section is made), then fills
|
|
83
|
+
it with a follow-up update. When the section is created, `section_name`
|
|
84
|
+
and `section_icon` override its default heading/icon (no follow-up
|
|
85
|
+
rename-section / section-icon needed).
|
|
74
86
|
"""
|
|
75
87
|
resume = self.get_resume()
|
|
76
88
|
existing = (resume.get("content") or {}).get(section)
|
|
@@ -80,8 +92,17 @@ class ContentMixin:
|
|
|
80
92
|
icon_key = existing.get("iconKey")
|
|
81
93
|
elif section in SECTION_META:
|
|
82
94
|
section_type, display_name, icon_key = SECTION_META[section]
|
|
95
|
+
elif re.fullmatch(r"custom\d+", section):
|
|
96
|
+
# FlowCV supports multiple custom sections (custom1, custom2, …);
|
|
97
|
+
# only custom1 is pre-declared in SECTION_META. Create any other
|
|
98
|
+
# customN as a generic custom section.
|
|
99
|
+
section_type, display_name, icon_key = "custom", "Custom", "star"
|
|
83
100
|
else:
|
|
84
101
|
raise SystemExit(f"unknown section (no meta): {section}")
|
|
102
|
+
if section_name is not None:
|
|
103
|
+
display_name = section_name
|
|
104
|
+
if section_icon is not None:
|
|
105
|
+
icon_key = section_icon
|
|
85
106
|
|
|
86
107
|
new_id = str(uuid.uuid4())
|
|
87
108
|
# Create: minimal entry + section meta (also creates the section).
|
|
@@ -98,9 +119,7 @@ class ContentMixin:
|
|
|
98
119
|
"createdAt": now, "updatedAt": now}
|
|
99
120
|
entry.update(sets or {})
|
|
100
121
|
if md:
|
|
101
|
-
|
|
102
|
-
field = {"profile": "text", "skill": "infoHtml"}.get(section, "description")
|
|
103
|
-
entry[field] = md_to_html(md)
|
|
122
|
+
entry[rich_field(section)] = md_to_html(md)
|
|
104
123
|
env = self.save_entry(section, entry)
|
|
105
124
|
if not env.get("success"):
|
|
106
125
|
raise SystemExit(f"created {section} entry {new_id} but failed to populate it: {env}")
|
|
@@ -115,9 +134,30 @@ class ContentMixin:
|
|
|
115
134
|
entry["updatedAt"] = self.now_iso()
|
|
116
135
|
return self.save_entry(section, entry)
|
|
117
136
|
|
|
118
|
-
def set_description(self, section, entry_id, md, field=
|
|
119
|
-
"""Set a rich-text field to md_to_html(md) on an entry.
|
|
120
|
-
|
|
137
|
+
def set_description(self, section, entry_id, md, field=None):
|
|
138
|
+
"""Set a rich-text field to md_to_html(md) on an entry. When `field` is
|
|
139
|
+
omitted, it defaults to the section's rich-text field (profile->text,
|
|
140
|
+
skill->infoHtml, else description)."""
|
|
141
|
+
return self.set_field(section, entry_id, field or rich_field(section), md_to_html(md))
|
|
142
|
+
|
|
143
|
+
def set_date(self, section, entry_id, year=None, month=None, day=None):
|
|
144
|
+
"""Set an entry's structured `date` object (used by publications, etc.).
|
|
145
|
+
|
|
146
|
+
Merges into the existing date; month/day left unset are hidden, so
|
|
147
|
+
`set_date(..., year=2018)` renders as a year-only date.
|
|
148
|
+
"""
|
|
149
|
+
resume = self.get_resume()
|
|
150
|
+
entry = dict(self.find_entry(resume, section, entry_id))
|
|
151
|
+
date = dict(entry.get("date") or {})
|
|
152
|
+
date["year"] = "" if year is None else str(year)
|
|
153
|
+
date["month"] = "" if month is None else str(month)
|
|
154
|
+
date["day"] = "" if day is None else str(day)
|
|
155
|
+
date["hideMonth"] = month is None
|
|
156
|
+
date["hideDay"] = day is None
|
|
157
|
+
entry["date"] = date
|
|
158
|
+
if "updatedAt" in entry:
|
|
159
|
+
entry["updatedAt"] = self.now_iso()
|
|
160
|
+
return self.save_entry(section, entry)
|
|
121
161
|
|
|
122
162
|
def hide_entry(self, section, entry_id, hidden=True):
|
|
123
163
|
"""Show/hide an entry (sets its `isHidden`). Hidden entries stay in the
|
|
@@ -6,9 +6,11 @@ converts a small markdown dialect into that markup:
|
|
|
6
6
|
blank line -> block separator
|
|
7
7
|
"## Heading" -> bold justified paragraph (a subheader)
|
|
8
8
|
"**Whole line bold**" -> bold justified paragraph
|
|
9
|
+
"***Whole line***" -> bold + italic justified paragraph (a sub-subheader)
|
|
9
10
|
"- item" -> bullet (consecutive lines become one <ul>)
|
|
10
11
|
anything else -> justified paragraph
|
|
11
12
|
inline **bold** -> <strong>bold</strong> (inside paragraphs and bullets)
|
|
13
|
+
inline [text](url) -> <a href="url">text</a> (inside paragraphs and bullets)
|
|
12
14
|
"""
|
|
13
15
|
import html
|
|
14
16
|
import re
|
|
@@ -17,8 +19,13 @@ J = ' style="text-align: justify"'
|
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
def _esc(s):
|
|
20
|
-
"""Escape text, then honor inline **bold
|
|
21
|
-
|
|
22
|
+
"""Escape text, then honor inline ***bold-italic***, **bold**, and
|
|
23
|
+
[text](url) links (triple-asterisks before double so they don't clash)."""
|
|
24
|
+
s = html.escape(s, quote=False)
|
|
25
|
+
s = re.sub(r"\*\*\*(.+?)\*\*\*", r"<strong><em>\1</em></strong>", s)
|
|
26
|
+
s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
|
|
27
|
+
return re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)",
|
|
28
|
+
r'<a target="_blank" rel="noopener noreferrer nofollow" href="\2">\1</a>', s)
|
|
22
29
|
|
|
23
30
|
|
|
24
31
|
def md_to_html(md):
|
|
@@ -41,6 +48,8 @@ def md_to_html(md):
|
|
|
41
48
|
flush()
|
|
42
49
|
if line.startswith("## "):
|
|
43
50
|
parts.append(f"<p{J}><strong>{html.escape(line[3:].strip(), quote=False)}</strong></p>")
|
|
51
|
+
elif len(line) > 6 and line.startswith("***") and line.endswith("***"):
|
|
52
|
+
parts.append(f"<p{J}><strong><em>{html.escape(line[3:-3].strip(), quote=False)}</em></strong></p>")
|
|
44
53
|
elif len(line) > 4 and line.startswith("**") and line.endswith("**") and line.count("**") == 2:
|
|
45
54
|
parts.append(f"<p{J}><strong>{html.escape(line[2:-2].strip(), quote=False)}</strong></p>")
|
|
46
55
|
else:
|
|
@@ -23,17 +23,17 @@ class ResumeMixin:
|
|
|
23
23
|
return env["data"]["resumes"]
|
|
24
24
|
|
|
25
25
|
# ---- create / duplicate / rename / delete -----------------------------
|
|
26
|
-
def _create_from(self, title, keep_content):
|
|
27
|
-
"""Create a new resume by cloning
|
|
26
|
+
def _create_from(self, title, keep_content, src=None):
|
|
27
|
+
"""Create a new resume by cloning a full resume object.
|
|
28
28
|
|
|
29
29
|
FlowCV's `create` needs a complete resume object (every NOT-NULL column),
|
|
30
|
-
so we clone
|
|
31
|
-
fresh id/uuid, drop the unique tokens (server regenerates), and
|
|
32
|
-
title. `keep_content=False` makes a blank resume that keeps the
|
|
33
|
-
identity (personalDetails) and design (customization);
|
|
34
|
-
is a full
|
|
30
|
+
so we clone a valid one — `src` (defaults to the current resume) — then
|
|
31
|
+
reassign a fresh id/uuid, drop the unique tokens (server regenerates), and
|
|
32
|
+
set the title. `keep_content=False` makes a blank resume that keeps the
|
|
33
|
+
same identity (personalDetails) and design (customization);
|
|
34
|
+
`keep_content=True` is a full copy. Returns the new resume id.
|
|
35
35
|
"""
|
|
36
|
-
src = self.get_resume()
|
|
36
|
+
src = self.get_resume() if src is None else src
|
|
37
37
|
clone = json.loads(json.dumps(src)) # deep copy
|
|
38
38
|
new_id = str(uuid.uuid4())
|
|
39
39
|
clone["id"] = new_id
|
|
@@ -59,6 +59,19 @@ class ResumeMixin:
|
|
|
59
59
|
title = (self.get_resume().get("title") or "Resume") + " (copy)"
|
|
60
60
|
return self._create_from(title, keep_content=True)
|
|
61
61
|
|
|
62
|
+
# ---- backup / restore -------------------------------------------------
|
|
63
|
+
def export_resume(self):
|
|
64
|
+
"""Return the full resume object (for a backup/snapshot)."""
|
|
65
|
+
return self.get_resume()
|
|
66
|
+
|
|
67
|
+
def import_resume(self, resume, title=None):
|
|
68
|
+
"""Restore a previously exported resume object into a NEW resume
|
|
69
|
+
(non-destructive — the current resume is untouched). Returns the new id.
|
|
70
|
+
"""
|
|
71
|
+
if title is None:
|
|
72
|
+
title = (resume.get("title") or "Resume") + " (restored)"
|
|
73
|
+
return self._create_from(title, keep_content=True, src=resume)
|
|
74
|
+
|
|
62
75
|
def rename_resume(self, title):
|
|
63
76
|
"""PATCH /resumes/rename_resume — set the resume's title."""
|
|
64
77
|
return self.request("resumes/rename_resume", method="PATCH",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flowcvcli
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Control a FlowCV resume from the command line or Python — content, design, templates, photo, publish and PDF export — via FlowCV's private JSON API. Zero dependencies.
|
|
5
5
|
Author: dannyota
|
|
6
6
|
License-Expression: MIT
|
|
@@ -31,12 +31,12 @@ Dynamic: license-file
|
|
|
31
31
|
# flowcvcli
|
|
32
32
|
|
|
33
33
|
[](https://pypi.org/project/flowcvcli/)
|
|
34
|
-
[](https://pypi.org/project/flowcvcli/)
|
|
35
35
|
[](LICENSE)
|
|
36
36
|
|
|
37
37
|
Control a [FlowCV](https://flowcv.com) resume from the **command line** or from
|
|
38
38
|
**Python** — content, header & links, **customization**, **templates**,
|
|
39
|
-
**avatar**, reorder/hide, multi-resume management, publish, and **PDF export**.
|
|
39
|
+
**avatar**, reorder/hide, multi-resume management, **backup/restore**, publish, and **PDF export**.
|
|
40
40
|
It drives FlowCV's private JSON API (the same calls the web app makes), so it
|
|
41
41
|
works for any FlowCV resume with your own session. **Zero dependencies** (Python
|
|
42
42
|
standard library only), so it's easy to drop into scripts and LLM agents.
|
|
@@ -96,10 +96,13 @@ flowcv duplicate ["Copy title"] # full copy of the current resume
|
|
|
96
96
|
flowcv rename "New Title" # rename the current resume
|
|
97
97
|
flowcv delete-resume --yes # permanent (refuses without --yes)
|
|
98
98
|
|
|
99
|
-
# content (markdown mini-format below); `add` creates the section if needed
|
|
99
|
+
# content (markdown mini-format below); `add` creates the section if needed.
|
|
100
|
+
# --set aliases (title/company/link) resolve per section (work->jobTitle, publication->title…)
|
|
100
101
|
flowcv add work --set title="Engineer" --set company="Acme" \
|
|
101
102
|
--set start=01/2022 --set end=Present --text $'- Did a measurable thing.'
|
|
102
|
-
flowcv
|
|
103
|
+
flowcv add custom2 --section-name "Open Source" --icon code --text "…" # heading+icon at creation
|
|
104
|
+
flowcv desc work <id> --file role.md # rich text; --field is optional (auto: profile=text, skill=infoHtml)
|
|
105
|
+
flowcv date publication <id> --year 2018 # structured date (year-only by default)
|
|
103
106
|
flowcv field work <id> employer --text "Acme Corp"
|
|
104
107
|
flowcv rm work <id>
|
|
105
108
|
|
|
@@ -109,7 +112,7 @@ flowcv hide work <id> ; flowcv show-entry work <id>
|
|
|
109
112
|
flowcv rename-section skill "Core Skills"
|
|
110
113
|
flowcv section-icon skill head-side-brain
|
|
111
114
|
flowcv rm-section custom1 --yes # delete a section + its entries
|
|
112
|
-
flowcv reorder-sections profile work skill education # one-column order
|
|
115
|
+
flowcv reorder-sections profile work skill education # set one-column order (no args = print current)
|
|
113
116
|
|
|
114
117
|
# header details & links (links are social entries: orcid, googlescholar, github…)
|
|
115
118
|
flowcv pd jobTitle --text "Security Leader"
|
|
@@ -131,6 +134,10 @@ flowcv download -o resume.pdf # the rendered PDF
|
|
|
131
134
|
flowcv download --token <webToken> -o out.pdf # any PUBLIC resume by its share token (no auth)
|
|
132
135
|
flowcv share | publish | unpublish
|
|
133
136
|
|
|
137
|
+
# backup / restore
|
|
138
|
+
flowcv export -o backup.json # full resume snapshot (JSON) — keep one before big edits
|
|
139
|
+
flowcv import backup.json # restore the snapshot into a NEW resume (non-destructive)
|
|
140
|
+
|
|
134
141
|
flowcv login # refresh the cached session
|
|
135
142
|
flowcv md2html --file role.md # preview HTML (offline)
|
|
136
143
|
```
|
|
@@ -159,6 +166,12 @@ fc.rename_section("skill", "Core Skills"); fc.delete_section("custom1")
|
|
|
159
166
|
fc.hide_entry("work", "id", hidden=True)
|
|
160
167
|
new_id = fc.create_resume("Second Resume") # or fc.duplicate_resume()
|
|
161
168
|
fc.rename_resume("New Title"); fc.delete_resume() # delete is permanent
|
|
169
|
+
fc.set_date("publication", "id", year=2018) # structured date (year-only)
|
|
170
|
+
|
|
171
|
+
# backup / restore
|
|
172
|
+
import json
|
|
173
|
+
json.dump(fc.export_resume(), open("backup.json", "w")) # full snapshot
|
|
174
|
+
new_id = fc.import_resume(json.load(open("backup.json"))) # restore into a NEW resume
|
|
162
175
|
```
|
|
163
176
|
|
|
164
177
|
### Build → render → check → improve
|
|
@@ -173,9 +186,11 @@ feedback loop for building a resume from raw info.
|
|
|
173
186
|
|---|---|
|
|
174
187
|
| blank line | block separator |
|
|
175
188
|
| `## Heading` / `**Whole line bold**` | bold subheader |
|
|
189
|
+
| `***Whole line***` | bold + italic subheader |
|
|
176
190
|
| `- item` | bullet (consecutive = one list) |
|
|
177
191
|
| anything else | justified paragraph |
|
|
178
|
-
| `**bold**` inline | `<strong
|
|
192
|
+
| `**bold**` / `***bold-italic***` inline | `<strong>` / `<strong><em>…</em></strong>` |
|
|
193
|
+
| `[text](url)` inline | link |
|
|
179
194
|
|
|
180
195
|
## How it works
|
|
181
196
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|