library-manager: add MPN column to all templates (no version bump); connected-folder copy; datasheet Gitea link

main
admin 2026-07-18 09:21:14 +00:00
parent e460f2d085
commit f56fe9d0a3
6 changed files with 178 additions and 90 deletions

View File

@ -55,6 +55,27 @@ and report it. A symbol that still holds Ultra-Librarian defaults is an incomple
Every other flow keeps the normal interactive asks below. Every other flow keeps the normal interactive asks below.
## Keep a copy in the connected folder — always, after every part push
So the operator ends up with the parts they worked on **on their own machine** — without
having to go into the admin Gitea to fetch them — the skill drops a copy of each pushed part
folder into the **connected session folder**. At the start of a Cowork session the operator
connects a folder; that folder is the destination. If no folder was explicitly connected, use
the session's output directory `/mnt/user-data/outputs/` (create it if missing) so the copy
still lands somewhere the operator can retrieve.
The rule is: **immediately after any successful part push to Gitea**, copy that part's
`<MPN>_<make>_<typeid>/` folder — with its files intact (`<tag>.xlsx`, datasheet, `.SchLib`,
`.PcbLib`) — into the connected folder. This is automatic and never a question, exactly like
the push itself. Details and the exact command are in *Copy the pushed folder to the connected
folder* below.
Two boundaries: (1) copy **only after the push succeeds** — a failed push must not leave a
misleading copy on the operator's machine; (2) this applies to **part** pushes only
(`push-part`, and the `commit-push` used by updates, backfill, and the `.SchLib` fill-and-push).
**Skill-repo** pushes (`push-skill`, template/changelog changes) involve no part folder and are
excluded.
## Inputs ## Inputs
- **A datasheet PDF whose filename is the MPN** (e.g. `BAT46WJ.pdf`). If it's a series - **A datasheet PDF whose filename is the MPN** (e.g. `BAT46WJ.pdf`). If it's a series
@ -256,7 +277,9 @@ python scripts/fill_templates.py part.json \
This writes `<tag>.xlsx` with column A = the tag, **Skill Version (col B)** and **Template This writes `<tag>.xlsx` with column A = the tag, **Skill Version (col B)** and **Template
Version (col C)** stamped from this typeid's current versions, and the four design columns Version (col C)** stamped from this typeid's current versions, and the four design columns
left blank for now. left blank for now. Every sheet also carries an **`MPN` column** (positioned just before
`Manufacturer`) that `fill_templates` populates with the part's bare MPN — it's filled from
`part["mpn"]` automatically, so no extra input is needed.
The part workbook has up to **two sheets**: The part workbook has up to **two sheets**:
@ -318,11 +341,9 @@ new-part run. Do not ask "would you like me to fill the symbol parameters?", do
a suggestion, and do not defer it to a later turn. Just do it, then report that it's done. a suggestion, and do not defer it to a later turn. Just do it, then report that it's done.
The SOP requires the fixed parameter set (Manufacturer, Manufacturer Part, Value, Tolerance, The SOP requires the fixed parameter set (Manufacturer, Manufacturer Part, Value, Tolerance,
Operating Temperature, RoHS, Datasheet, Process, Vecmocon Part Code, …) on every symbol, **plus Operating Temperature, RoHS, Datasheet, Process, Vecmocon Part Code, …) on every symbol, so a
the symbol's Description**, so a part folder whose `.SchLib` has not been filled is **incomplete part folder whose `.SchLib` has not been filled is **incomplete and must not be pushed**. Build
and must not be pushed**. Build the part's `params.json` from the values the engineer just the part's `params.json` from the values the engineer just verified in step 5 and run:
verified in step 5 — including `"description"`, set to the **same** strict `Class_TYPEID` string
you put in the part's Excel — and run:
```bash ```bash
python scripts/schlib_write.py \ python scripts/schlib_write.py \
@ -331,6 +352,12 @@ python scripts/schlib_write.py \
--out <stage>/<tag>/<symbol_name>.SchLib --out <stage>/<tag>/<symbol_name>.SchLib
``` ```
`params.json` must carry **`description`** and **`comment`** alongside `parameters`. The
`description` is the strict `Class_TYPEID_…` string you already built for the workbook — reuse
the *exact same string* so the symbol's ComponentDescription and the Excel `Description` column
never diverge. The `comment` is the MPN. If the script warns that `description` is missing, treat
that as an error: fix `params.json` and re-run before pushing.
See *Mandatory symbol parameters* below for the full parameter set, the value-shorthand rules, See *Mandatory symbol parameters* below for the full parameter set, the value-shorthand rules,
and the Ultra-Librarian default-stripping behaviour — read `references/schlib_parameters.md` and the Ultra-Librarian default-stripping behaviour — read `references/schlib_parameters.md`
before building the parameter set. The values are already verified at this point, so this step before building the parameter set. The values are already verified at this point, so this step
@ -354,22 +381,38 @@ python scripts/gitea_components.py push-part --folder <stage>/<tag> --typeid <ty
This places the folder at `components/<Class>/<tag>/` — creating the Class folder if it This places the folder at `components/<Class>/<tag>/` — creating the Class folder if it
doesn't exist yet, or pushing into it if it does — and commits and pushes. Confirm to the doesn't exist yet, or pushing into it if it does — and commits and pushes. Confirm to the
user where it landed. user where it landed. **Then copy the folder to the connected folder** (next section).
### Copy the pushed folder to the connected folder — automatic, after every part push
Right after the push above **succeeds**, copy the just-pushed `<MPN>_<make>_<typeid>/` folder
into the connected session folder so the operator has a local copy without touching Gitea. Use
the folder the operator connected at the start of the session; if none was connected, fall back
to `/mnt/user-data/outputs/`.
```bash
DEST="${CONNECTED_FOLDER:-/mnt/user-data/outputs}" # the folder connected this session
mkdir -p "$DEST/<tag>"
cp -r <stage>/<tag>/. "$DEST/<tag>/" # xlsx, datasheet, .SchLib, .PcbLib
```
Copy the folder as `<tag>/` (i.e. `<MPN>_<make>_<typeid>/`) — just the part folder, not the
`<Class>/` parent — so the connected folder simply accumulates one folder per part the operator
worked on. If that folder already exists there (a re-run or an update), refresh it so the local
copy matches what was just pushed. Then tell the user the copy is in the connected folder.
This copy step runs after **every** part push, including the `commit-push` used by *Updating an
existing part*, *Backfilling existing parts*, and the `.SchLib` *fill-and-push* — run the same
three lines against that part's folder once its push succeeds. It does **not** run for
`push-skill` (skill-repo/template/changelog pushes carry no part folder).
## Mandatory symbol parameters (.SchLib) ## Mandatory symbol parameters (.SchLib)
The SOP (§5) requires every schematic symbol to carry a fixed parameter set in its Altium The SOP (§5) requires every schematic symbol to carry a fixed parameter set in its Altium
properties — `Component Type`, `Manufacturer`, `Manufacturer Part`, `Value`, `Tolerance`, properties — `Component Type`, `Manufacturer`, `Manufacturer Part`, `Value`, `Tolerance`,
`Operating Temperature`, `ROHS`, `Datasheet`, `Process`, `Vecmocon Part Code`, and the two `Operating Temperature`, `ROHS`, `Datasheet`, `Process`, `Vecmocon Part Code`, and the two
second-source fields — with the **Comment** set to the MPN and the **Description** set to the second-source fields — with the **Comment** set to the MPN. The skill can stamp these onto the
part's strict `Class_TYPEID` string. The skill stamps all of these onto the symbol from the symbol from the datasheet.
datasheet in a single pass.
**Description is written too, always.** It isn't a parameter — it's the `ComponentDescription`
field in the symbol's header, which Ultra-Librarian ships as the placeholder text `Description`.
So it must be written explicitly or the symbol shows the literal word "Description" in Altium
even when every parameter is right. Pass `"description"` in `params.json` (it falls back to
`parameters.Description`), and use the **same** string as the part's Excel so the two agree.
This runs **as its own task too**, not only inside new-part creation: whenever the user hands This runs **as its own task too**, not only inside new-part creation: whenever the user hands
over one or more `.SchLib` files and wants their parameters filled/updated (e.g. "\schlib", "add over one or more `.SchLib` files and wants their parameters filled/updated (e.g. "\schlib", "add
@ -388,7 +431,12 @@ UL `Component_Type` — replaced by Vecmocon's own spaced `Component Type`). Kin
- **Read from the datasheet** (don't just echo given text — open the PDF and fill the real, - **Read from the datasheet** (don't just echo given text — open the PDF and fill the real,
verified values): `Value` = the **value only** in shorthand (e.g. `1u`, `12p`, `10k` — no verified values): `Value` = the **value only** in shorthand (e.g. `1u`, `12p`, `10k` — no
package), plus `Manufacturer`, `Manufacturer Part`, `Operating Temperature`, `Tolerance`, package), plus `Manufacturer`, `Manufacturer Part`, `Operating Temperature`, `Tolerance`,
`ROHS`, `Datasheet`, and `Process` by inference from the package. `ROHS`, and `Process` by inference from the package.
- **Derived link**`Datasheet` = the **Gitea URL to the pushed `<MPN>_data.<ext>`**, so the
symbol clicks straight through to the exact datasheet in the library. Build it from `GIT_HOST`
+ `LIBRARY_REPO` and the part's Class/tag
(`https://<GIT_HOST>/<LIBRARY_REPO>/raw/branch/main/<Class>/<tag>/<MPN>_data.<ext>`); the full
rule is in `references/schlib_parameters.md`.
- **Leave blank for now**`Manufacturer 2` / `Manufacturer Part 2` (the second source). Don't - **Leave blank for now**`Manufacturer 2` / `Manufacturer Part 2` (the second source). Don't
populate these by default; they stay hidden in Altium until filled later. (An optional populate these by default; they stay hidden in Altium until filled later. (An optional
cross-reference search to find a second source is documented in cross-reference search to find a second source is documented in
@ -402,8 +450,7 @@ Leave any genuinely-unknown field blank — the SOP hides blank parameters, so a
empty until filled. The full method for the second-source search is in empty until filled. The full method for the second-source search is in
`references/schlib_parameters.md`. `references/schlib_parameters.md`.
Collect the values into a `params.json` — with `"description"` alongside `"parameters"` — and Collect the values into a `params.json` and write them into the symbol:
write them into the symbol:
```bash ```bash
python scripts/schlib_write.py --schlib <in>.SchLib --params params.json --out <stage>/<tag>/<sym>.SchLib python scripts/schlib_write.py --schlib <in>.SchLib --params params.json --out <stage>/<tag>/<sym>.SchLib
@ -464,7 +511,8 @@ the symbol clearly isn't this component, stop and flag it rather than pushing th
Pushing without asking is consistent with the skill's standing rule that pushing is automatic; Pushing without asking is consistent with the skill's standing rule that pushing is automatic;
what's new here is that the Excel fill is automatic too. Tell the user which parameters you what's new here is that the Excel fill is automatic too. Tell the user which parameters you
filled, that the symbol was updated, and where in Gitea it landed. (A part-data fill isn't a filled, that the symbol was updated, and where in Gitea it landed. (A part-data fill isn't a
template change, so nothing version-bumps and the changelog is untouched.) template change, so nothing version-bumps and the changelog is untouched.) **Then copy the
part folder to the connected folder** — see *Copy the pushed folder to the connected folder*.
## Per-typeid versioning ## Per-typeid versioning
@ -477,6 +525,14 @@ into that typeid's rows (cols B and C), so a row always records the template/ski
was built against. `append_parameter.py` does the bump; `common.py` is the single source for was built against. `append_parameter.py` does the bump; `common.py` is the single source for
reading and writing these numbers. reading and writing these numbers.
**One deliberate exception already applied:** the `MPN` column (before `Manufacturer`) was
added to **every** typeid sheet as a one-off structural change **without** bumping any
`template_version`/`skill_version` and **without** a changelog row — it's a bookkeeping column,
not a spec parameter, and versions were intentionally left where they were. Don't treat the
existing sheets' MPN column as a version discrepancy to "correct." (This no-bump behaviour is
specific to that one change; genuine parameter additions still go through `append_parameter.py`
and bump normally.)
## The changelog ## The changelog
`append_parameter.py` maintains one **global** changelog as an Excel workbook at `append_parameter.py` maintains one **global** changelog as an Excel workbook at
@ -519,6 +575,9 @@ and tell the user the previous sheets were updated:
python scripts/gitea_components.py commit-push --root work/ --message "backfill <param> into <typeid>" python scripts/gitea_components.py commit-push --root work/ --message "backfill <param> into <typeid>"
``` ```
Then copy each rebuilt part folder into the connected folder (see *Copy the pushed folder to
the connected folder*) so the operator's local copies reflect the backfill too.
## Updating an existing part ## Updating an existing part
When the duplicate check finds the part (or the user asks to revise one that's already in When the duplicate check finds the part (or the user asks to revise one that's already in

Binary file not shown.

Binary file not shown.

View File

@ -22,16 +22,9 @@ inside Altium; keep it as a fallback if a particular file doesn't round-trip.)
Use these exact Altium parameter names (they must match the symbol, per the SOP screenshot). Use these exact Altium parameter names (they must match the symbol, per the SOP screenshot).
The **Comment** field is set to the MPN (SOP §4), and the **Description** field is the strict The **Comment** field is set to the MPN (SOP §4), and the **Description** field is the strict
string from `references/description_format.md`. string from `references/description_format.md` — the *same* string as the part's Excel
`Description` column. Both are written by `schlib_write.py` from the `comment` and `description`
**The Description is not a parameter record.** It is the `ComponentDescription` field of the keys of `params.json`; neither is optional.
component's `RECORD=1` header — the box shown at *Properties → General → Description*, above the
Parameters table. Ultra-Librarian ships it as the literal placeholder text `Description`, so a
symbol whose parameters are all correctly filled will *still* show the word "Description" in
Altium unless that header field is rewritten. `schlib_write.py` now sets it in the same pass as
the parameters: pass `"description"` in `params.json`, or let it fall back to your
`parameters.Description`. Always give it the same strict `Class_TYPEID` string you wrote into the
part's Excel, so the symbol and the workbook agree.
| Parameter | Source | Notes | | Parameter | Source | Notes |
|-----------|--------|-------| |-----------|--------|-------|
@ -45,16 +38,33 @@ part's Excel, so the symbol and the workbook agree.
| `Vecmocon Part Code` | engineer | internal code (e.g. `VECESC2421`) — **not on the datasheet**; ask | | `Vecmocon Part Code` | engineer | internal code (e.g. `VECESC2421`) — **not on the datasheet**; ask |
| `Operating Temperature` | datasheet | full range, e.g. `-55 °C to +125 °C` | | `Operating Temperature` | datasheet | full range, e.g. `-55 °C to +125 °C` |
| `Tolerance` | datasheet | e.g. `1%` (or `±1%`) | | `Tolerance` | datasheet | e.g. `1%` (or `±1%`) |
| `Datasheet` | datasheet | source URL or document reference (SOP marks this optional) | | `Datasheet` | derived | **Gitea link to the pushed `<MPN>_data.<ext>`** — see below (SOP marks this optional) |
| `ROHS` | datasheet | RoHS compliance, `Yes`/`No` | | `ROHS` | datasheet | RoHS compliance, `Yes`/`No` |
**Read the datasheet-sourced values from the actual datasheet — don't echo whatever text was **Read the datasheet-sourced values from the actual datasheet — don't echo whatever text was
handed to you.** Open the PDF, find each real value (`Value`, `Manufacturer Part`, handed to you.** Open the PDF, find each real value (`Value`, `Manufacturer Part`,
`Manufacturer`, `Operating Temperature`, `Tolerance`, `Datasheet`, `ROHS`, and `Process` by `Manufacturer`, `Operating Temperature`, `Tolerance`, `ROHS`, and `Process` by
inference from the package), and fill them verified. An honest blank beats a guess — the SOP inference from the package), and fill them verified. An honest blank beats a guess — the SOP
hides blank parameters, so a gap just stays empty until someone fills it. Only **one** field is hides blank parameters, so a gap just stays empty until someone fills it. Only **one** field is
purely internal and must come from the engineer: `Vecmocon Part Code` — ask for it. purely internal and must come from the engineer: `Vecmocon Part Code` — ask for it.
**The `Datasheet` field is a link, not a value read off the PDF.** Point it at the datasheet
this skill pushes into the part's Gitea folder (`<MPN>_data.<ext>`), so opening the symbol in
Altium clicks straight through to the exact datasheet used. Build it from the config
(`GIT_HOST`, `LIBRARY_REPO` in `config/gitea.env`) and the part's Class + tag:
```
https://<GIT_HOST>/<LIBRARY_REPO>/raw/branch/main/<Class>/<MPN>_<make>_<typeid>/<MPN>_data.<ext>
```
e.g. `https://gitea.vecmocon.com/nitishKumar/library/raw/branch/main/Diode/BAT46WJ_Nexperia_SCH/BAT46WJ_data.pdf`.
Use the `raw/branch/<default-branch>` form (not `src/…`) so the URL opens the PDF directly;
`<Class>` is `class_folder(typeid)` and `<MPN>_<make>_<typeid>` is the same tag the folder is
pushed under, so the link always resolves to the file that lands in Gitea. Fill this link even
on the first push of a new part — the push and the symbol write happen in the same run, so the
URL is valid as soon as the part folder lands. (If for some reason the datasheet isn't being
pushed to Gitea, leave `Datasheet` blank rather than writing a link that won't resolve.)
The second-source pair (`Manufacturer 2` / `Manufacturer Part 2`) is **left blank for now** The second-source pair (`Manufacturer 2` / `Manufacturer Part 2`) is **left blank for now**
don't populate it by default. It simply stays hidden in Altium until someone fills it later. don't populate it by default. It simply stays hidden in Altium until someone fills it later.
@ -85,11 +95,22 @@ confirm** with the specs you compared.
Collect the values into a `params.json` (same spirit as `part.json`). `component` is the Collect the values into a `params.json` (same spirit as `part.json`). `component` is the
symbol's Library Ref (from `altium_refs.py`); omit it to apply to every component in the lib. symbol's Library Ref (from `altium_refs.py`); omit it to apply to every component in the lib.
Two top-level fields sit alongside `parameters` and are **both mandatory**:
- **`description`** → the symbol's `ComponentDescription`. This must be **byte-identical to the
part's Excel `Description` column** — the same strict `Class_TYPEID_…` string built to
`references/description_format.md`. Build it once, use it in both places; never let the two
drift. A symbol must never ship carrying Altium's `No Description Available` placeholder.
- **`comment`** → the Comment record's Text, which per SOP §4 is always the exact MPN.
Both are patched **in place**, so the records keep their other fields (Comment keeps its
Location/Color/FontID); only the parameter block is dropped and re-added.
```json ```json
{ {
"component": "CC0402FRNPO9BN120", "component": "CC0402FRNPO9BN120",
"description": "Capacitor_CER_12pF_50V_±1%_0402_c0g",
"comment": "CC0402FRNPO9BN120", "comment": "CC0402FRNPO9BN120",
"description": "Capacitor_CER_12pF_50V_±1%_0402_NPO",
"parameters": { "parameters": {
"Component Type": "Capacitor", "Component Type": "Capacitor",
"Value": "12pF_0402", "Value": "12pF_0402",
@ -101,7 +122,7 @@ symbol's Library Ref (from `altium_refs.py`); omit it to apply to every componen
"Vecmocon Part Code": "VECESC2421", "Vecmocon Part Code": "VECESC2421",
"Operating Temperature": "-55 °C to +125 °C", "Operating Temperature": "-55 °C to +125 °C",
"Tolerance": "1%", "Tolerance": "1%",
"Datasheet": "https://www.lcsc.com/datasheet/C326662.pdf", "Datasheet": "https://gitea.vecmocon.com/nitishKumar/library/raw/branch/main/Capacitor/CC0402FRNPO9BN120_YAGEO_CER/CC0402FRNPO9BN120_data.pdf",
"ROHS": "Yes" "ROHS": "Yes"
} }
} }
@ -117,9 +138,16 @@ python scripts/schlib_write.py --schlib <in>.SchLib --params params.json --out <
`params.json` may carry a `"remove"` list (defaults to `["Manufacturer_Name", `params.json` may carry a `"remove"` list (defaults to `["Manufacturer_Name",
"Manufacturer_Part_Number", "Copyright", "Component_Type"]`); those Ultra-Librarian defaults are "Manufacturer_Part_Number", "Copyright", "Component_Type"]`); those Ultra-Librarian defaults are
stripped and the SOP params — including the spaced `Component Type` = Class — added. The script targets the component named in `"component"` (its Library Ref / storage name), stripped and the SOP params — including the spaced `Component Type` = Class — added. The script
or every component if omitted, and self-checks the output re-opens as a valid OLE. Deliver the also sets `ComponentDescription` from `"description"` and the Comment Text from `"comment"`, and
warns on stderr if `"description"` is missing — **treat that warning as an error** and re-run
with the Description rather than shipping the symbol. It targets the component named in `"component"` (its Library Ref / storage name),
or every component if omitted, and self-checks the output re-opens as a valid OLE with the
params and Description present. Deliver the
resulting `.SchLib`, and have the engineer open it in Altium once to confirm it loads, then Save resulting `.SchLib`, and have the engineer open it in Altium once to confirm it loads, then Save
to Server with a revision note per the SOP. to Server with a revision note per the SOP.
The script needs the `olefile` package (`pip install olefile`) — its only non-stdlib
dependency. Install it if the import fails.
Scope note: the d Scope note: the d

View File

@ -95,6 +95,7 @@ def part_to_row(part, headers, design=None):
if key == norm(COL_TAG): row[h] = tag if key == norm(COL_TAG): row[h] = tag
elif key == norm(COL_SKILL_VER): row[h] = sver elif key == norm(COL_SKILL_VER): row[h] = sver
elif key == norm(COL_TEMPLATE_VER): row[h] = tver elif key == norm(COL_TEMPLATE_VER): row[h] = tver
elif key == norm("MPN"): row[h] = part.get("mpn", vals.get("mpn", ""))
elif key == norm("Manufacturer"): row[h] = part.get("manufacturer", vals.get("manufacturer", "")) elif key == norm("Manufacturer"): row[h] = part.get("manufacturer", vals.get("manufacturer", ""))
elif h in DESIGN_COLS: row[h] = dmap.get(key, vals.get(key, "")) elif h in DESIGN_COLS: row[h] = dmap.get(key, vals.get(key, ""))
else: row[h] = vals.get(key, "") else: row[h] = vals.get(key, "")

View File

@ -16,28 +16,29 @@ What it does to the target component's Data stream:
and the UL ``Component_Type`` (Vecmocon adds its own spaced ``Component Type`` = Class instead); and the UL ``Component_Type`` (Vecmocon adds its own spaced ``Component Type`` = Class instead);
override this default set with a "remove" list in params.json, override this default set with a "remove" list in params.json,
- adds/updates the SOP parameters from params.json (see references/schlib_parameters.md), - adds/updates the SOP parameters from params.json (see references/schlib_parameters.md),
- sets the component's ComponentDescription (the Description shown in Altium's Properties - sets the component's ComponentDescription (RECORD=1) from params.json "description" — the
panel) this lives in the RECORD=1 header, NOT in a parameter record, and Ultra-Librarian strict Description string from references/description_format.md, the SAME text that goes in
ships it as the literal placeholder "Description", so it must be rewritten explicitly, the part's Excel Description column. Mandatory: a symbol must never ship with Altium's
- leaves pins, graphics, the Comment and all other records exactly as they were. "No Description Available" placeholder,
- sets the Comment record's Text from params.json "comment" (SOP §4: Comment = the MPN),
patching that record in place so its position/colour/font are preserved,
- leaves pins, graphics and all other records exactly as they were.
Usage: Usage:
python schlib_write.py --schlib IN.SchLib --params params.json --out OUT.SchLib python schlib_write.py --schlib IN.SchLib --params params.json --out OUT.SchLib
params.json (same shape altium_params.py uses): params.json (same shape altium_params.py uses):
{"component":"JMK105BJ105KV-F", # LibRef / component-storage name; omit -> all comps {"component":"JMK105BJ105KV-F", # LibRef / component-storage name; omit -> all comps
"description":"Capacitor_CER_1uF_35V_±10%_0402_x7r", # optional; else parameters.Description "description":"Capacitor_CER_1uF_10v_±10%_0402_x5r", # -> ComponentDescription; == Excel Description
"comment":"JMK105BJ105KV-F", # -> Comment record Text (the MPN)
"parameters":{"Value":"1u","Manufacturer":"Taiyo Yuden","Component Type":"Capacitor", ...}, "parameters":{"Value":"1u","Manufacturer":"Taiyo Yuden","Component Type":"Capacitor", ...},
"remove":["Manufacturer_Name","Manufacturer_Part_Number","Copyright","Component_Type"]} # optional; default "remove":["Manufacturer_Name","Manufacturer_Part_Number","Copyright","Component_Type"]} # optional; default
The Description is the same strict Class_TYPEID string written to the part's Excel
(references/description_format.md), so symbol and workbook always agree.
IMPORTANT: this writes Altium's own binary format from outside Altium. It is validated to IMPORTANT: this writes Altium's own binary format from outside Altium. It is validated to
re-open as a well-formed OLE with every other stream byte-identical, but ALWAYS open the result re-open as a well-formed OLE with every other stream byte-identical, but ALWAYS open the result
in Altium once to confirm it loads before relying on it. in Altium once to confirm it loads before relying on it.
""" """
import argparse, json, struct, sys, hashlib import argparse, json, re, struct, sys, hashlib
import olefile import olefile
FREESECT=0xFFFFFFFF; ENDOFCHAIN=0xFFFFFFFE; FATSECT=0xFFFFFFFD FREESECT=0xFFFFFFFF; ENDOFCHAIN=0xFFFFFFFE; FATSECT=0xFFFFFFFD
@ -108,6 +109,21 @@ def _param_record(idx, name, value):
return struct.pack('<I',len(payload))+payload return struct.pack('<I',len(payload))+payload
def _reframe(text):
"""Re-wrap an edited text record: Altium frames each as <4-byte LE length><payload><NUL>,
so any edit that changes the payload length must rewrite the prefix."""
payload=text.encode('latin-1')+b'\x00'
return struct.pack('<I',len(payload))+payload
def _sub_field(text, field, value):
"""Replace |<field>=... up to the next | (or end of record). Adds the field if absent."""
pat=re.compile(r'\|'+re.escape(field)+r'=[^|]*')
if pat.search(text):
return pat.sub('|'+field+'='+value, text, count=1)
return text+'|'+field+'='+value
def _leading_text_records(data): def _leading_text_records(data):
"""Split Data into (list of leading text-record byte-blocks, tail bytes). The leading run is """Split Data into (list of leading text-record byte-blocks, tail bytes). The leading run is
the contiguous |RECORD=| text records at the start (RECORD=1 + the parameter block); it the contiguous |RECORD=| text records at the start (RECORD=1 + the parameter block); it
@ -129,40 +145,15 @@ def _rec_name(block):
return t.split('|Name=')[1].split('|')[0] if '|Name=' in t else None return t.split('|Name=')[1].split('|')[0] if '|Name=' in t else None
def _text_block(text): def edit_data(data, params, remove, description=None, comment=None):
"""Re-wrap an edited text record as its 4-byte length prefix + NUL-terminated payload."""
payload=text.encode('latin-1')+b'\x00'
return struct.pack('<I', len(payload))+payload
def _set_description(blk, description):
"""Set ComponentDescription on a RECORD=1 component-header block.
The symbol's Description (Altium: Properties -> General -> Description) is NOT a parameter
record it's the ComponentDescription field of the RECORD=1 header. Ultra-Librarian ships
the literal placeholder `ComponentDescription=Description`, so unless this is rewritten the
symbol shows the word "Description" in Altium even after every SOP parameter is filled.
"""
text=blk[4:-1].decode('latin-1')
if not text.startswith('|RECORD=1|'):
return blk
if 'ComponentDescription=' in text:
parts=text.split('|')
for i,tok in enumerate(parts):
if tok.startswith('ComponentDescription='):
parts[i]='ComponentDescription='+description
text='|'.join(parts)
elif '|PartCount=' in text: # field absent entirely -> insert it
text=text.replace('|PartCount=', f'|ComponentDescription={description}|PartCount=', 1)
else:
text=text+f'|ComponentDescription={description}'
return _text_block(text)
def edit_data(data, params, remove, description=None):
"""Return a new Data stream: drop `remove` params, drop any SOP-name params (re-added """Return a new Data stream: drop `remove` params, drop any SOP-name params (re-added
fresh), keep everything else, then append the SOP params. When `description` is given, the fresh), keep everything else, then append the SOP params. Pins/graphics/tail untouched.
RECORD=1 ComponentDescription field is rewritten too. Pins/graphics/tail untouched."""
`description` (when given) overwrites ComponentDescription on the RECORD=1 header this is
the strict Description string, the same text as the part's Excel Description column, and is
required on every Vecmocon symbol. `comment` overwrites the Comment record's Text (the MPN).
Both are patched IN PLACE so the records keep their other fields (Comment keeps its
Location/Color/FontID), unlike the parameter block which is dropped and re-added."""
leading, tail = _leading_text_records(data) leading, tail = _leading_text_records(data)
sop_names=set(params) sop_names=set(params)
kept=[] kept=[]
@ -170,8 +161,11 @@ def edit_data(data, params, remove, description=None):
nm=_rec_name(blk) nm=_rec_name(blk)
if nm is not None and (nm in remove or nm in sop_names): if nm is not None and (nm in remove or nm in sop_names):
continue # drop UL duplicates + stale SOP copies continue # drop UL duplicates + stale SOP copies
if description is not None and nm is None: txt=blk[4:-1].decode('latin-1')
blk=_set_description(blk, description) # RECORD=1 header carries the Description if description is not None and txt.startswith('|RECORD=1|'):
blk=_reframe(_sub_field(txt, 'ComponentDescription', description))
elif comment is not None and nm=='Comment' and '|RECORD=41|' in txt:
blk=_reframe(_sub_field(txt, 'Text', comment))
kept.append(blk) kept.append(blk)
added=[_param_record(20+i, nm, val) for i,(nm,val) in enumerate(params.items())] added=[_param_record(20+i, nm, val) for i,(nm,val) in enumerate(params.items())]
return b''.join(kept)+b''.join(added)+tail return b''.join(kept)+b''.join(added)+tail
@ -259,9 +253,12 @@ def write_params(schlib, params_json, out):
component=params_json.get("component") or None component=params_json.get("component") or None
fields=params_json.get("parameters", {}) or {} fields=params_json.get("parameters", {}) or {}
remove=params_json.get("remove", DEFAULT_REMOVE) remove=params_json.get("remove", DEFAULT_REMOVE)
# The symbol's Description: taken from "description", else the "Description" parameter if the description=params_json.get("description") or None
# caller put it there (it's the same strict Class_TYPEID string that goes in the part's Excel). comment=params_json.get("comment") or None
description=params_json.get("description") or fields.get("Description") or None if not description:
print("WARNING: no 'description' in params.json — the symbol keeps whatever "
"ComponentDescription it has (often Altium's 'No Description Available'). "
"Vecmocon symbols must carry the Description; add it and re-run.", file=sys.stderr)
entries, paths, content = read_container(schlib) entries, paths, content = read_container(schlib)
# target Data stream sid(s): a stream named 'Data' whose parent storage == component (or all) # target Data stream sid(s): a stream named 'Data' whose parent storage == component (or all)
@ -273,7 +270,7 @@ def write_params(schlib, params_json, out):
if not targets: if not targets:
sys.exit(f"no component Data stream found" + (f" for '{component}'" if component else "")) sys.exit(f"no component Data stream found" + (f" for '{component}'" if component else ""))
for sid in targets: for sid in targets:
content[sid]=edit_data(content[sid], fields, remove, description) content[sid]=edit_data(content[sid], fields, remove, description, comment)
blob=rebuild(entries, content) blob=rebuild(entries, content)
open(out,'wb').write(blob) open(out,'wb').write(blob)
@ -282,21 +279,24 @@ def write_params(schlib, params_json, out):
if not olefile.isOleFile(out): if not olefile.isOleFile(out):
sys.exit("ERROR: rebuilt file is not a valid OLE — aborting") sys.exit("ERROR: rebuilt file is not a valid OLE — aborting")
ole=olefile.OleFileIO(out) ole=olefile.OleFileIO(out)
ok=True; desc_ok=(description is None) ok=True
for e in ole.listdir(streams=True): for e in ole.listdir(streams=True):
if e[-1].lower()=="data": if e[-1].lower()=="data":
t=ole.openstream(e).read().decode('latin-1','ignore') t=ole.openstream(e).read().decode('latin-1','ignore')
for nm in fields: for nm in fields:
if f"|Name={nm}|" not in t and f"|Name={nm}\x00" not in t and f"Name={nm}" not in t: if f"|Name={nm}|" not in t and f"|Name={nm}\x00" not in t and f"Name={nm}" not in t:
ok=False ok=False
if description is not None and f"ComponentDescription={description}|" in t: if description and f"|ComponentDescription={description}|" not in t \
desc_ok=True and not t.split("|ComponentDescription=")[-1].startswith(description):
ok=False
ole.close() ole.close()
n_comp=len(targets) n_comp=len(targets)
desc_note=f"; description set" if description else "; no description given" extra=[]
flag='[self-check OK]' if (ok and desc_ok) else '[WARN: verify params/description]' if description: extra.append(f"description={description!r}")
if comment: extra.append(f"comment={comment!r}")
print(f"wrote {out} ({len(fields)} params into {n_comp} component(s); removed {remove}" print(f"wrote {out} ({len(fields)} params into {n_comp} component(s); removed {remove}"
f"{desc_note}) {flag}") + ("; " + ", ".join(extra) if extra else "") + ") "
f"{'[self-check OK]' if ok else '[WARN: verify params]'}")
def main(): def main():