Keynote Renumbering/Reordering by Sheet View

I have done a little digging onto how keynote tags display the number when the Revit setting are set to By Sheet for the numbering method. What I have found so far in testing is that the numbering is all based on the first instance of the Key Value that was placed. I have found that the only way (my current hack) to renumbering the keynote displayed value is to Cut keynote tags and Paste them back.

Example:

Keynote 1

Keynote 2

Keynote 3

If i want Keynote 2 and 3 to swap, I first collect all the Keynote 2’s and Cut them. This moves the current Keynote 3 to be the new 2. Then I paste back the previously cut Keynote 2 and they are now the new Keynote 3.

It works.

My testing so far has been a sheet with a single view.

I came up with this so far:

I am hoping this has potential and others in the community could do a better job than me at taking this further to a more “Move Up/Down Reorder” type tool.

# -*- coding: utf-8 -*-

"""
NXGN | Select Keynote Tags From List

Purpose:
- Build visible By Sheet keynote numbering from Keynote Tag Element IDs.
- Show the user a checkbox list of keynote display numbers.
- User selects one or more keynote numbers.
- Tool selects all matching Keynote Tags.

Logic:
- Each Key Value group has one or more Keynote Tag Element IDs.
- The lowest Element ID in each Key Value group is treated as the first placed tag.
- Groups are sorted by their lowest Element ID.
- That sorted order becomes the visible By Sheet numbering order.

Revit / pyRevit
IronPython 2.7
No f-strings
"""

import clr

clr.AddReference("RevitAPI")
clr.AddReference("RevitAPIUI")

from Autodesk.Revit.DB import (
    BuiltInCategory,
    BuiltInParameter,
    FilteredElementCollector,
    ElementId,
    ViewSheet,
    Viewport
)

from pyrevit import revit, forms, script

from System.Collections.Generic import List


# ------------------------------------------------------------
# Setup
# ------------------------------------------------------------

doc = revit.doc
uidoc = revit.uidoc
view = doc.ActiveView


# ------------------------------------------------------------
# Helpers
# ------------------------------------------------------------

def safe_str(value):
    try:
        if value is None:
            return ""
        return str(value)
    except:
        return ""


def clean_value(value):
    return safe_str(value).strip()


def clean_key(value):
    return clean_value(value).lower()


def values_match(a, b):
    return clean_key(a) == clean_key(b)


def add_unique(values, value):
    value = clean_value(value)

    if not value:
        return

    existing = []
    for v in values:
        existing.append(clean_key(v))

    if clean_key(value) not in existing:
        values.append(value)


def add_unique_element_id(values, element_id):
    if element_id is None:
        return

    try:
        new_int = element_id.IntegerValue
    except:
        return

    for existing_id in values:
        try:
            if existing_id.IntegerValue == new_int:
                return
        except:
            pass

    values.append(element_id)


def get_element_name(elem):
    if elem is None:
        return ""

    try:
        name = elem.Name
        if name:
            return safe_str(name)
    except:
        pass

    try:
        p = elem.get_Parameter(BuiltInParameter.SYMBOL_NAME_PARAM)
        if p:
            value = p.AsString()
            if value:
                return safe_str(value)
    except:
        pass

    try:
        p = elem.get_Parameter(BuiltInParameter.ALL_MODEL_TYPE_NAME)
        if p:
            value = p.AsString()
            if value:
                return safe_str(value)
    except:
        pass

    return ""


def get_param_value(param):
    if param is None:
        return ""

    try:
        storage_type = safe_str(param.StorageType)

        if storage_type == "String":
            value = param.AsString()
            if value:
                return clean_value(value)
            return ""

        if storage_type == "Integer":
            return clean_value(param.AsInteger())

        if storage_type == "Double":
            value = param.AsValueString()
            if value:
                return clean_value(value)
            return clean_value(param.AsDouble())

        if storage_type == "ElementId":
            eid = param.AsElementId()
            if eid and eid != ElementId.InvalidElementId:
                elem = doc.GetElement(eid)
                if elem:
                    elem_name = get_element_name(elem)
                    if elem_name:
                        return "{0} ({1})".format(elem_name, eid.IntegerValue)
                return clean_value(eid.IntegerValue)
            return ""

    except:
        pass

    try:
        value = param.AsValueString()
        if value:
            return clean_value(value)
    except:
        pass

    try:
        value = param.AsString()
        if value:
            return clean_value(value)
    except:
        pass

    return ""


def get_all_named_parameter_values(elem, param_name):
    """
    Important:
    Revit can have duplicate parameter names.
    LookupParameter can return the blank one.
    This loops all parameters and gets every non-empty value with that name.
    """

    values = []

    if elem is None:
        return values

    try:
        params = elem.Parameters
    except:
        params = []

    for p in params:
        try:
            pname = p.Definition.Name
        except:
            pname = ""

        if pname == param_name:
            add_unique(values, get_param_value(p))

    return values


def get_key_values_from_tag(tag):
    """
    Reads stored Key Value from a Keynote Tag.
    Handles duplicate 'Key Value' parameters.
    """

    values = []

    if tag is None:
        return values

    for v in get_all_named_parameter_values(tag, "Key Value"):
        add_unique(values, v)

    try:
        p = tag.get_Parameter(BuiltInParameter.KEYNOTE_PARAM)
        if p:
            add_unique(values, get_param_value(p))
    except:
        pass

    return values


def get_primary_key_value_from_tag(tag):
    values = get_key_values_from_tag(tag)

    if values:
        return values[0]

    return ""


def get_active_sheet_for_view(active_view):
    """
    If active view is a sheet:
        return that sheet.

    If active view is a model/detail/drafting view:
        find the sheet where that view is placed.
    """

    if isinstance(active_view, ViewSheet):
        return active_view

    try:
        viewports = (
            FilteredElementCollector(doc)
            .OfClass(Viewport)
            .ToElements()
        )

        for vp in viewports:
            try:
                if vp.ViewId == active_view.Id:
                    sheet = doc.GetElement(vp.SheetId)
                    if sheet:
                        return sheet
            except:
                pass
    except:
        pass

    return None


def get_views_on_sheet(sheet):
    views = []

    if sheet is None:
        return views

    try:
        viewports = (
            FilteredElementCollector(doc)
            .OfClass(Viewport)
            .ToElements()
        )

        for vp in viewports:
            try:
                if vp.SheetId == sheet.Id:
                    v = doc.GetElement(vp.ViewId)
                    if v:
                        views.append(v)
            except:
                pass
    except:
        pass

    return views


def collect_keynote_tags_from_view(target_view):
    if target_view is None:
        return []

    try:
        return list(
            FilteredElementCollector(doc, target_view.Id)
            .OfCategory(BuiltInCategory.OST_KeynoteTags)
            .WhereElementIsNotElementType()
            .ToElements()
        )
    except:
        return []


def add_unique_tag(tags, tag):
    if tag is None:
        return

    try:
        new_int = tag.Id.IntegerValue
    except:
        return

    for existing in tags:
        try:
            if existing.Id.IntegerValue == new_int:
                return
        except:
            pass

    tags.append(tag)


def collect_sheet_keynote_tags(sheet):
    """
    Collects keynote tags from all views placed on the sheet,
    plus keynote tags directly on the sheet.
    """

    tags = []

    if sheet is None:
        return tags

    placed_views = get_views_on_sheet(sheet)

    for v in placed_views:
        view_tags = collect_keynote_tags_from_view(v)

        for tag in view_tags:
            add_unique_tag(tags, tag)

    sheet_tags = collect_keynote_tags_from_view(sheet)

    for tag in sheet_tags:
        add_unique_tag(tags, tag)

    return tags


def collect_selection_target_tags(active_view, sheet):
    """
    If user runs from a plan/detail view:
        select tags in the active view only.

    If user runs from a sheet:
        select tags from all views placed on the sheet,
        plus any tags directly on the sheet.
    """

    if isinstance(active_view, ViewSheet):
        return collect_sheet_keynote_tags(sheet)

    return collect_keynote_tags_from_view(active_view)


def collect_numbering_source_tags(active_view, sheet):
    """
    Numbering Method = By Sheet is sheet-based.

    If the active view is placed on a sheet:
        use all keynote tags on that sheet to build the display numbering order.

    If no sheet is found:
        fall back to active view tags.
    """

    if sheet is not None:
        sheet_tags = collect_sheet_keynote_tags(sheet)

        if sheet_tags:
            return sheet_tags

    return collect_selection_target_tags(active_view, sheet)


def build_key_value_groups_from_tags(tags):
    groups = {}

    for tag in tags:
        key_value = get_primary_key_value_from_tag(tag)

        if not key_value:
            continue

        group_key = clean_key(key_value)

        if group_key not in groups:
            groups[group_key] = {
                "key_value": key_value,
                "tag_ids": [],
                "min_id": None,
                "count": 0
            }

        try:
            tag_int_id = tag.Id.IntegerValue
        except:
            tag_int_id = None

        add_unique_element_id(groups[group_key]["tag_ids"], tag.Id)
        groups[group_key]["count"] = len(groups[group_key]["tag_ids"])

        if tag_int_id is not None:
            if groups[group_key]["min_id"] is None:
                groups[group_key]["min_id"] = tag_int_id
            else:
                if tag_int_id < groups[group_key]["min_id"]:
                    groups[group_key]["min_id"] = tag_int_id

    return groups


def build_display_number_items(groups):
    """
    Sort groups by their lowest tag Element Id.
    Assign display numbers starting at 1.
    """

    group_list = []

    for group_key in groups:
        item = groups[group_key]

        if item["min_id"] is None:
            continue

        group_list.append(item)

    group_list = sorted(group_list, key=lambda x: x["min_id"])

    display_items = []

    display_number = 1

    for item in group_list:
        display_items.append({
            "display_number": display_number,
            "display_text": safe_str(display_number),
            "key_value": item["key_value"],
            "tag_ids": item["tag_ids"],
            "min_id": item["min_id"],
            "count": item["count"]
        })

        display_number += 1

    return display_items


def get_tag_ids_for_key_value(tags, key_value):
    ids = []

    for tag in tags:
        tag_key_value = get_primary_key_value_from_tag(tag)

        if values_match(tag_key_value, key_value):
            add_unique_element_id(ids, tag.Id)

    return ids


def select_element_ids(element_ids):
    id_list = List[ElementId]()

    for eid in element_ids:
        id_list.Add(eid)

    uidoc.Selection.SetElementIds(id_list)


def format_option_label(item, target_count):
    return "{0}    |    {1}    |    {2} tag(s)".format(
        item["display_text"],
        item["key_value"],
        target_count
    )


# ------------------------------------------------------------
# Main
# ------------------------------------------------------------

sheet = get_active_sheet_for_view(view)

numbering_tags = collect_numbering_source_tags(view, sheet)
selection_tags = collect_selection_target_tags(view, sheet)

if not numbering_tags:
    script.exit()

if not selection_tags:
    script.exit()

groups = build_key_value_groups_from_tags(numbering_tags)
display_items = build_display_number_items(groups)

if not display_items:
    script.exit()


# ------------------------------------------------------------
# Build SelectFromList options
# ------------------------------------------------------------

label_to_item = {}
option_labels = []

for item in display_items:
    target_ids = get_tag_ids_for_key_value(selection_tags, item["key_value"])
    target_count = len(target_ids)

    if target_count < 1:
        continue

    label = format_option_label(item, target_count)

    option_labels.append(label)
    label_to_item[label] = item

if not option_labels:
    script.exit()


selected_labels = forms.SelectFromList.show(
    option_labels,
    title="NXGN | Select Keynote Tags",
    button_name="Select Keynote Tags",
    multiselect=True
)

if not selected_labels:
    script.exit()


# ------------------------------------------------------------
# Select matching keynote tags
# ------------------------------------------------------------

selected_ids = []

for label in selected_labels:
    item = label_to_item.get(label)

    if not item:
        continue

    target_ids = get_tag_ids_for_key_value(selection_tags, item["key_value"])

    for eid in target_ids:
        add_unique_element_id(selected_ids, eid)

if not selected_ids:
    script.exit()

select_element_ids(selected_ids)