# -*- coding: utf-8 -*- """Where an extracted design spec is saved, and what is still unresolved in it. Nothing here touches Revit, a document, a form or the extraction itself -- it is paths and text in, paths and text out. That is deliberate, and it is the same boundary `specformat.py` sits on: the rules the Extract Design button follows when it asks the engineer where to put a file, and when it lists what the reader could not place, are exercised by an ordinary CPython test with no licence and no model (see `tests/conftest.py`). THE TWO THINGS THIS EXISTS FOR ------------------------------ 1. A SECOND COPY, NOT A MOVE. The canonical spec has to stay in `/_ticketqc/specs/` -- `designspec.list_specs` reads that folder and nothing else, so Ticket QC and Panel Lift Check only ever see what is there. A spec "saved" somewhere else and nowhere else would look extracted and be invisible to every check downstream. So the engineer's chosen path gets a COPY, and `targets()` is the rule for naming it. 2. UNRESOLVED BY NAME, NOT BY COUNT. "3 unresolved notes" tells a reader there is something to look at and nothing about whether to look now. The entries are short, they are the whole point of the extraction being honest, and they belong on the page. Written for IronPython 2.7 as well as CPython 3: no f-strings, no pathlib. """ # pylint: disable=invalid-name import io import os from vibe import specformat from vibe.compat import as_text, string_types # The pointer file that remembers where an extraction was last saved, kept # beside the rest of the job's QC data. It is a convenience, not a setting: # it only stops the dialog opening in the wrong folder every single press. # Where the files BELONG is the knowledge/config layers' business, and a # pointer that is stale or gone must never do anything but be ignored. POINTER = 'last-extract-dir.txt' def safe_name(name): """A design number as a filename. The one rule -- `designspec._safe` delegates here so the copy cannot be named differently from the original. """ out = [] for ch in (name or 'unnamed'): out.append(ch if (ch.isalnum() or ch in '._- ') else '_') return ''.join(out).strip() # ------------------------------------------------------------ where it goes def default_name(designs, ext='yaml'): """The name to seed the save dialog with. One design puts its own number in the box, which is the name the file already has and the one the engineer expects to see. Several designs cannot share a file name, so the box carries the count instead -- it is the FOLDER that is being picked in that case, and `targets()` says so. """ designs = [d for d in (designs or []) if d] if len(designs) == 1: return '{0}.{1}'.format(safe_name(designs[0]), ext) if not designs: return 'design-extraction.{0}'.format(ext) return 'design-extraction-{0}-designs.{1}'.format(len(designs), ext) def targets(chosen, designs, ext='yaml'): """[(design, path)] -- where each design's copy is written. A single design is written to EXACTLY the path that was typed. A file name box that quietly does nothing is a trap (CLAUDE.md says this about the config file and it is the same trap here), so where the name can be honoured it is. Several designs cannot all be one file. The folder that was picked is used and each design keeps its own name, because a design number is how every other part of this tool finds the file -- renaming them to `something-1.yaml`, `something-2.yaml` would make the set unreadable. The button says this in the output rather than leaving it to be discovered. """ designs = [d for d in (designs or []) if d] if not chosen or not designs: return [] if len(designs) == 1: return [(designs[0], chosen)] folder = os.path.dirname(chosen) or '.' suffix = os.path.splitext(chosen)[1] or ('.' + ext) return [(d, os.path.join(folder, safe_name(d) + suffix)) for d in designs] def href(path): """A `file:///` URL for a Windows path. A bare Windows path in a markdown link is a DEAD LINK in pyRevit's output panel: backslashes and no scheme, so the browser control renders the link and navigates nowhere. Same fix as PanelLiftCheck -- only the href gets the URL form; the visible text stays the real path, because that is what gets copied into Explorer. """ return 'file:///' + (path or '').replace('\\', '/') def link(path): """`[the real path](file:///the/real/path)`, ready for print_md.""" return '[{0}]({1})'.format(path, href(path)) def same_file(one, other): """Whether two paths name the same file. The dialog opens on the folder the specs already live in, so pressing Save without moving is a perfectly ordinary thing to do -- and a "copy" onto itself is at best a pointless rewrite of a file something else may be reading. Compared by path rather than by `os.path.samefile`, which IronPython 2.7 does not carry on every Windows build; both paths come from this process, so normcase is enough. """ if not one or not other: return False # no path is never "the same file" return (os.path.normcase(os.path.abspath(one)) == os.path.normcase(os.path.abspath(other))) # ------------------------------------------------- remembering the last one def _pointer_path(root): return os.path.join(root or '', POINTER) def recall_dir(root): """The folder an extraction was last saved to, or '' -- never a guess. A pointer to a folder that has gone is not a folder: it is dropped, and the caller falls back to its own default. Silently seeding a dialog with a path that does not exist is how a save lands somewhere nobody meant. """ try: with io.open(_pointer_path(root), 'r', encoding='utf-8') as fh: folder = fh.read().strip() except Exception: return '' if folder and os.path.isdir(folder): return folder return '' def remember_dir(root, folder): """Record where the engineer just saved. Failure here is never fatal.""" if not folder: return '' try: with io.open(_pointer_path(root), 'w', encoding='utf-8') as fh: fh.write(as_text(folder)) return folder except Exception: return '' # ------------------------------------------------------ what is not resolved def unresolved_entries(spec): """[(text, reason)] from the spec's `unresolved` list, verbatim. Tolerant on the way in: an entry written as a bare string by an older extractor still reads as a note with no reason given, rather than disappearing out of the one list whose whole job is not losing lines. """ out = [] for entry in (specformat.get_path(spec, 'unresolved', []) or []): if isinstance(entry, string_types): out.append((entry, '')) elif isinstance(entry, dict): out.append((entry.get('text') or '', entry.get('reason') or '')) else: out.append(('{0}'.format(entry), '')) return [(t, r) for t, r in out if t] def shared_reason(entries): """The one reason every entry gives, or ''. The mechanical reader gives the same sentence to every note it could not place. Printed once above the list it is an explanation; printed on all fourteen lines it is what stops the list being read. """ reasons = set([r for _t, r in (entries or [])]) if len(reasons) == 1: return list(reasons)[0] return '' # The fields something downstream actually reads off a spec. A null here is # not necessarily an error -- the summary may genuinely not state it -- but a # blank field is the OTHER way a spec is unresolved, and it is invisible # unless it is named. `strand_layout.columns_in` is on the list knowing that # the regex reader never fills it (see `designspec.build_spec`): that is # exactly the sort of blank a reader should be able to see. KEY_FIELDS = ( 'concrete.fc_psi', 'concrete.fci_psi', 'concrete.unit_weight_pcf', 'thickness_in', 'envelope.length.max_ft', 'envelope.width.max_ft', 'reinforcing.wwf.mesh', ) LIST_FIELDS = ( 'reinforcing.strand_groups', 'reinforcing.strand_layout.columns_in', ) def blank_fields(spec): """Dotted paths of the fields downstream reads that are still empty.""" out = [path for path in KEY_FIELDS if specformat.get_path(spec, path) is None] out.extend([path for path in LIST_FIELDS if not (specformat.get_path(spec, path) or [])]) return out def problem_sentence(entries, problems=None): """Why this design's section opens itself, or None when it need not. UNRESOLVED NOTES AND VALIDATION FAILURES OPEN IT. Blank fields do NOT, deliberately: the mechanical reader leaves the strand matrix empty on every single spec it writes, so opening on a blank field would open every section on every press and the fold's contract -- if nothing below is open, nothing needs you -- would be worth nothing. Blanks are listed inside the section, where a reader who opens it sees them. """ bits = [] if entries: bits.append('{0} note{1} nothing could place'.format( len(entries), '' if len(entries) == 1 else 's')) if problems: bits.append('{0} validation problem{1}'.format( len(problems), '' if len(problems) == 1 else 's')) if not bits: return None return ' and '.join(bits) + ' -- read them before this spec is trusted' def summary_line(entries, blanks): """The one-line summary a folded section carries on its header.""" return '{0} unresolved note{1}, {2} field{3} blank'.format( len(entries or []), '' if len(entries or []) == 1 else 's', len(blanks or []), '' if len(blanks or []) == 1 else 's')