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.
Files changed (26) hide show
  1. {flowcvcli-0.2.0/flowcvcli.egg-info → flowcvcli-0.5.2}/PKG-INFO +22 -7
  2. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/README.md +21 -6
  3. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/__init__.py +1 -1
  4. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/cli.py +82 -12
  5. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/content.py +50 -10
  6. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/markup.py +11 -2
  7. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/resume.py +21 -8
  8. {flowcvcli-0.2.0 → flowcvcli-0.5.2/flowcvcli.egg-info}/PKG-INFO +22 -7
  9. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/.env.example +0 -0
  10. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/LICENSE +0 -0
  11. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/MANIFEST.in +0 -0
  12. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/docs/API.md +0 -0
  13. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/docs/RENDERING.md +0 -0
  14. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/__main__.py +0 -0
  15. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/api.py +0 -0
  16. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/client.py +0 -0
  17. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/config.py +0 -0
  18. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/customization.py +0 -0
  19. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/personal.py +0 -0
  20. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli/photo.py +0 -0
  21. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli.egg-info/SOURCES.txt +0 -0
  22. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli.egg-info/dependency_links.txt +0 -0
  23. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli.egg-info/entry_points.txt +0 -0
  24. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/flowcvcli.egg-info/top_level.txt +0 -0
  25. {flowcvcli-0.2.0 → flowcvcli-0.5.2}/pyproject.toml +0 -0
  26. {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.0
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
  [![PyPI](https://img.shields.io/pypi/v/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
34
- [![Python](https://img.shields.io/pypi/pyversions/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
34
+ [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://pypi.org/project/flowcvcli/)
35
35
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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 desc work <id> --file role.md
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>bold</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
  [![PyPI](https://img.shields.io/pypi/v/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
4
- [![Python](https://img.shields.io/pypi/pyversions/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
4
+ [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://pypi.org/project/flowcvcli/)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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 desc work <id> --file role.md
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>bold</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
 
@@ -5,4 +5,4 @@ from .content import SECTION_META, label_of
5
5
  from .markup import html_to_text, md_to_html
6
6
 
7
7
  __all__ = ["FlowCV", "Config", "SECTION_META", "label_of", "md_to_html", "html_to_text"]
8
- __version__ = "0.2.0"
8
+ __version__ = "0.5.2"
@@ -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
- ALIASES = {"title": "jobTitle", "company": "employer", "start": "startDateNew",
19
- "end": "endDateNew", "link": "employerLink"}
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[ALIASES.get(k, k)] = v
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
- _result(_fc(a).reorder_sections(a.ids, layout=a.layout), f"reorder-sections ({a.layout})")
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
- _result(_fc(a).set_description(a.section, a.entry, _read(a.file, a.text), field=a.field),
161
- f"{a.section}/{a.entry[:8]}.{a.field}")
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"); s.add_argument("ids", nargs="+", help="section ids in the desired order")
292
- s.add_argument("--layout", default="one", help="column layout to reorder (one|two|mix; default one)")
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="description")
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 a `description`
72
- HTML field. Creates the entry first (so a missing section is made),
73
- then fills it with a follow-up update.
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
- # rich text lives in a section-specific field (profile->text, skill->infoHtml)
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="description"):
119
- """Set a rich-text field to md_to_html(md) on an entry."""
120
- return self.set_field(section, entry_id, field, md_to_html(md))
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
- return re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html.escape(s, quote=False))
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 the current one's full object.
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 the current resumeguaranteeing validity — then reassign a
31
- fresh id/uuid, drop the unique tokens (server regenerates), and set the
32
- title. `keep_content=False` makes a blank resume that keeps the same
33
- identity (personalDetails) and design (customization); `keep_content=True`
34
- is a full duplicate. Returns the new resume id.
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.0
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
  [![PyPI](https://img.shields.io/pypi/v/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
34
- [![Python](https://img.shields.io/pypi/pyversions/flowcvcli.svg)](https://pypi.org/project/flowcvcli/)
34
+ [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://pypi.org/project/flowcvcli/)
35
35
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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 desc work <id> --file role.md
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>bold</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