function doStorage(value)
{
    return [(value / 1048576).toFixed(0), value];
}

function doImage(image)
{
    let display = "",
        value = image.file,
        filter = "";

    if (image.release) {
        // Display the release name nicely
        display = `${image.file} <span class="releaseName">(${image.release})</span>`;
        filter = `${image.file} (${image.release})`;
    } else {
        display = image.file;
        filter = image.file;
    }

    return [display, value, filter];
}

function doTimestamp(value)
{
    const ts = ST.convertTimestamp(value, true);

    return [
        ts[0] === true ? ts[1] : "?",
        value
    ];
}

function formatUptime(seconds)
{
    let parts = [];

    // Days
    if (seconds >= 86400) {
        const d = Math.floor(seconds / 86400);

        parts.push(`${d}d`);
        seconds -= d * 86400;
    }

    // Hours
    if (seconds >= 3600) {
        const h = Math.floor(seconds / 3600);

        parts.push(`${h}h`);
        seconds -= h * 3600;
    }

    // Minutes
    if (seconds >= 60) {
        const m = Math.floor(seconds / 60);

        parts.push(`${m}m`);
        seconds -= m * 60;
    }

    // Seconds (avoid adding "0s" to the end, unless there are no other parts)
    if (seconds > 0 || parts.length == 0)
        parts.push(`${seconds}s`);

    return parts.join(', ');
}

// Table column definitions
const COLUMN_DEFINITIONS = {
    abitti_version: {
        key: "abitti_version",
        title: "<%= t('columns.devices.abitti_version') %>",
        type: ST.ColumnType.STRING,
        transform: entry => [entry.abitti_version, entry.abitti_version, entry.abitti_version],
    },

<% unless @is_servers %>
    allow_guest: {
        key: "allow_guest",
        title: "<%= t('columns.devices.allow_guest') %>",
        type: ST.ColumnType.BOOL,
    },
<% end %>

    audio_sink: {
        key: "audio_sink",
        title: "<%= t('columns.devices.audio_sink') %>",
        type: ST.ColumnType.STRING,
        export_name: "audio_out",
    },

    audio_src: {
        key: "audio_src",
        title: "<%= t('columns.devices.audio_src') %>",
        type: ST.ColumnType.STRING,
        export_name: "audio_in",
    },

<% unless @is_servers %>
    auto_updates: {
        key: "auto_updates",
        title: "<%= t('columns.devices.auto_updates') %>",
        type: ST.ColumnType.BOOL,
    },

    autopower_mode: {
        key: "autopower_mode",
        title: "<%= t('columns.devices.autopower_mode') %>",
        type: ST.ColumnType.STRING,
    },

    autopower_on: {
        key: "autopower_on",
        title: "<%= t('columns.devices.autopower_on') %>",
        type: ST.ColumnType.NUMERIC,
    },

    autopower_off: {
        key: "autopower_off",
        title: "<%= t('columns.devices.autopower_off') %>",
        type: ST.ColumnType.NUMERIC,
    },
<% end %>

<% if @is_servers %>
    available_images: {
        key: "available_images",
        title: "<%= t('columns.servers.available_images') %>",
        type: ST.ColumnType.STRING,
        transform: entry => {
            let display = [],
                value = [],
                filter = [];

            for (const i of entry.available_images) {
                if (i.release) {
                    display.push(`${i.file} <span class="releaseName">(${i.release})</span>`);
                    filter.push(`${i.file} ${i.release}`);
                } else {
                    display.push(i.file);
                    filter.push(i.file);
                }

                value.push(i.file);
            }

            return [display.join('<br>'), value.join(','), filter.join(',')];
        },
    },
<% end %>

    bat_cap: {
        key: "bat_cap",
        title: "<%= t('columns.devices.bat_cap') %>",
        type: ST.ColumnType.NUMERIC,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT,
    },

    bat_pcnt: {
        key: "bat_pcnt",
        title: "<%= t('columns.devices.bat_pcnt') %>",
        type: ST.ColumnType.NUMERIC,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT,
    },

    bat_volts: {
        key: "bat_volts",
        title: "<%= t('columns.devices.bat_volts') %>",
        type: ST.ColumnType.NUMERIC,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT,
    },

    bat_serial: {
        key: "bat_serial",
        title: "<%= t('columns.devices.bat_serial') %>",
        type: ST.ColumnType.STRING,
    },

    bat_vendor: {
        key: "bat_vendor",
        title: "<%= t('columns.devices.bat_vendor') %>",
        type: ST.ColumnType.STRING,
    },

    bios_date: {
        key: "bios_date",
        title: "<%= t('columns.devices.bios_date') %>",
        type: ST.ColumnType.STRING,
    },

    bios_vendor: {
        key: "bios_vendor",
        title: "<%= t('columns.devices.bios_vendor') %>",
        type: ST.ColumnType.STRING,
    },

    bios_version: {
        key: "bios_version",
        title: "<%= t('columns.devices.bios_version') %>",
        type: ST.ColumnType.STRING,
    },

    conf: {
        key: "conf",
        title: "<%= t('columns.puavoconf') %>",
        type: ST.ColumnType.STRING,
        flags: ST.ColumnFlag.ARRAY,
        alias: ["puavo_conf", "puavoconf"],
        export_name: "puavoconf",
    },

    cpu: {
        key: "cpu",
        title: "<%= t('columns.devices.cpu') %>",
        type: ST.ColumnType.STRING,
    },

    created: {
        key: "created",
        title: "<%= t('columns.created') %>",
        type: ST.ColumnType.UNIXTIME,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT,
    },

    current_image: {
        key: "current_image",
        title: "<%= t('columns.devices.current_image') %>",
        type: ST.ColumnType.STRING,
        transform: entry => doImage(entry.current_image),
    },

    default_printer: {
        key: "default_printer",
        title: "<%= t('columns.devices.default_printer') %>",
        type: ST.ColumnType.STRING,
    },

    desc: {
        key: "desc",
        title: "<%= t('columns.devices.description') %>",
        type: ST.ColumnType.STRING,
        transform: entry => [ST.escapeHTML(entry.desc), entry.desc],
        alias: ["description"],
    },

    notes: {
        key: "notes",
        title: "<%= t('columns.notes') %>",
        type: ST.ColumnType.STRING,
        transform: entry => [ST.escapeHTML(entry.notes).replaceAll("\r", "").replaceAll("\n", "<br>"), entry.notes],
    },

    df_home: {
        key: "df_home",
        title: "<%= t('columns.devices.df_home') %>",
        type: ST.ColumnType.NUMERIC,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT | ST.ColumnFlag.F_STORAGE,
        transform: entry => doStorage(entry.df_home),
    },

    df_images: {
        key: "df_images",
        title: "<%= t('columns.devices.df_images') %>",
        type: ST.ColumnType.NUMERIC,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT | ST.ColumnFlag.F_STORAGE,
        transform: entry => doStorage(entry.df_images),
    },

    df_state: {
        key: "df_state",
        title: "<%= t('columns.devices.df_state') %>",
        type: ST.ColumnType.NUMERIC,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT | ST.ColumnFlag.F_STORAGE,
        transform: entry => doStorage(entry.df_state),
    },

    df_tmp: {
        key: "df_tmp",
        title: "<%= t('columns.devices.df_tmp') %>",
        type: ST.ColumnType.NUMERIC,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT | ST.ColumnFlag.F_STORAGE,
        transform: entry => doStorage(entry.df_tmp),
    },

    df_imageoverlays: {
        key: "df_imageoverlays",
        title: "<%= t('columns.devices.df_imageoverlays') %>",
        type: ST.ColumnType.NUMERIC,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT | ST.ColumnFlag.F_STORAGE,
        transform: entry => doStorage(entry.df_imageoverlays),
    },

    display_name: {
        key: "display_name",
        title: "<%= t('columns.devices.display_name') %>",
        type: ST.ColumnType.STRING,
    },

<% unless @is_servers %>
    expiration: {
        key: "expiration",
        title: "<%= t('columns.devices.expiration') %>",
        type: ST.ColumnType.UNIXTIME,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT | ST.ColumnFlag.F_NULLABLE,

        transform: entry => {
            const [valid, ts] = ST.convertTimestamp(entry.expiration)
            let html = "";

            if (valid && entry._has_expired)
                html = `<span class="invalidData">${ts}</span>`;
            else html = ts;

            return [html, entry.expiration, entry.expiration];
        }
    },
<% end %>

    hd: {
        key: "hd",
        title: "<%= t('columns.devices.hwinfo_hd_size') %>",
        type: ST.ColumnType.NUMERIC,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT | ST.ColumnFlag.F_STORAGE,
        transform: entry => doStorage(entry.hd),
        alias: ["hard_drive"],
    },

    hd_ssd: {
        key: "hd_ssd",
        title: "<%= t('columns.devices.hwinfo_is_ssd') %>",
        type: ST.ColumnType.BOOL,
    },

    hn: {
        key: "hn",
        title: "<%= t('columns.devices.hostname') %>",
        type: ST.ColumnType.STRING,
        transform: entry => {
            let extra = "";

<% unless @is_servers %>
            if (entry.reset_from && !entry.reset_fulfilled)
                extra = ` <i class="icon-attention iconError" title="<%= t('devices.index.pending_reset') %>"></i>`;

            if (entry._has_expired)
                extra += `<i class="icon-clock" title="<%= t('devices.index.expired_device') %>"></i>`;
<% end %>

            return [
                `<a href="${entry.link}">${ST.escapeHTML(entry.hn)}</a>${extra}`,
                entry.hn
            ];
        },
        alias: ["hostname"],
        export_name: "hostname",
    },

    hw_time: {
        key: "hw_time",
        title: "<%= t('columns.devices.hwinfo_time') %>",
        type: ST.ColumnType.UNIXTIME,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT,
    },

    have_smart: {
        key: "have_smart",
        title: "<%= t('columns.devices.have_smart') %>",
        type: ST.ColumnType.BOOL,
    },

    id: {
        key: "id",
        title: "<%= t('columns.puavoid') %>",
        type: ST.ColumnType.NUMERIC,
    },

    image: {
        key: "image",
        title: "<%= t('columns.devices.image') %>",
        type: ST.ColumnType.STRING,
        transform: entry => doImage(entry.image),
    },

    image_series: {
        key: "image_series",
        title: "<%= t('columns.devices.image_series') %>",
        type: ST.ColumnType.STRING,
        flags: ST.ColumnFlag.ARRAY,
    },

    krn_args: {
        key: "krn_args",
        title: "<%= t('columns.devices.kernel_args') %>",
        type: ST.ColumnType.STRING,
        alias: ["kernel_args"],
        export_name: "kernel_args",
    },

    krn_ver: {
        key: "krn_ver",
        title: "<%= t('columns.devices.kernel_version') %>",
        type: ST.ColumnType.STRING,
        alias: ["kernel_version"],
        export_name: "kernel_version",
    },

    ll_uid: {
        key: "ll_uid",
        title: "<%= t('columns.devices.last_login_uid') %>",
        type: ST.ColumnType.STRING,
    },

    ll_time: {
        key: "ll_time",
        title: "<%= t('columns.devices.last_login_time') %>",
        type: ST.ColumnType.UNIXTIME,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT,
    },

    location: {
        key: "location",
        title: "<%= t('columns.devices.location') %>",
        type: ST.ColumnType.STRING,
        flags: ST.ColumnFlag.ARRAY,
    },

    loc_lat: {
        key: "loc_lat",
        title: "<%= t('columns.devices.loc_lat') %>",
        type: ST.ColumnType.STRING,
    },

    loc_lon: {
        key: "loc_lon",
        title: "<%= t('columns.devices.loc_lon') %>",
        type: ST.ColumnType.STRING,
    },

    lspci: {
        key: "lspci",
        title: "<%= t('columns.devices.lspci') %>",
        type: ST.ColumnType.STRING,
        flags: ST.ColumnFlag.ARRAY,
    },

    lsusb: {
        key: "lsusb",
        title: "<%= t('columns.devices.lsusb') %>",
        type: ST.ColumnType.STRING,
        flags: ST.ColumnFlag.ARRAY,
    },

    mac: {
        key: "mac",
        title: "<%= t('columns.devices.mac') %>",
        type: ST.ColumnType.STRING,
        flags: ST.ColumnFlag.ARRAY,
    },

    mfer: {
        key: "mfer",
        title: "<%= t('columns.devices.manufacturer') %>",
        type: ST.ColumnType.STRING,
        alias: ["manufacturer"],
        export_name: "manufacturer",
    },

    model: {
        key: "model",
        title: "<%= t('columns.devices.model') %>",
        type: ST.ColumnType.STRING,
    },

    modified: {
        key: "modified",
        title: "<%= t('columns.modified') %>",
        type: ST.ColumnType.UNIXTIME,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT,
    },

    monitors_xml: {
        key: "monitors_xml",
        title: "<%= t('columns.devices.monitors_xml') %>",
        type: ST.ColumnType.STRING,
        transform: entry => {
            // It's actually XML, but HTML escaping can handle it
            return ["<pre>" + ST.escapeHTML(entry.monitors_xml[0]) + "</pre>", entry.monitors_xml];
        },
    },

<% if @is_organisation %>
    school: {
        key: "school",
        title: "<%= t('columns.school') %>",
        type: ST.ColumnType.STRING,
        transform: entry => {
            // Makes filtering by school name work as expected, but the exported CSV
            // will contain display names. Can't have everything.
            return [
                entry.school[1],
                entry.school[1]
            ];
        }
    },
<% end %>

<% if @is_servers %>
    schools: {
        key: "schools",
        title: "<%= t('columns.servers.schools') %>",
        type: ST.ColumnType.STRING,
        transform: entry => {
            let display = [],
                value = [];

            for (const sch of entry.schools) {
                if (sch.valid) {
                    display.push(`<a href="${sch.link}">${sch.title}</a>`);
                    value.push(sch.title);
                } else {
                    display.push(`<span class="missingData">${sch.dn}</span>`);
                    value.push('?');
                }
            }

            return [display.join("<br>"), value.join(','), value.join(',')];
        },
    },
<% end %>

    user: {
        key: "user",
        title: "<%= t('columns.devices.primary_user') %>",
        type: ST.ColumnType.STRING,
        transform: entry => {
            if (entry.user.valid) {
                return [
                    `<a href="${entry.user.link}">${entry.user.title}</a>`,
                    entry.user.title,
                    entry.user.title
                ]
            } else {
                return [
                    `<span class="missingData">${entry.user.dn}</span>`,
                    entry.user.dn,
                    entry.user.dn
                ]
            }
        },
    },

<% unless @is_servers %>
    personal_admin: {
        key: "personal_admin",
        title: "<%= t('columns.devices.personal_admin') %>",
        type: ST.ColumnType.BOOL,
        export_name: "personally_administered",
    },

    personal_device: {
        key: "personal_device",
        title: "<%= t('columns.devices.personal_device') %>",
        type: ST.ColumnType.BOOL,
    },
<% end %>

    printer_uri: {
        key: "printer_uri",
        title: "<%= t('columns.devices.printer_uri') %>",
        type: ST.ColumnType.STRING,
    },

    puavopkgs: {
        key: "puavopkgs",
        title: "<%= t('columns.devices.puavopkgs') %>",
        type: ST.ColumnType.STRING,
        transform: entry => {
            if (entry.puavopkgs === undefined || entry.puavopkgs.installed === undefined)
                return [null, null, null];

            let html = `<ul class="margin-0 padding-0 no-list-bullets">`;
            let text = "";

            for (const [id, pkg] of Object.entries(entry.puavopkgs.installed)) {
                html += `<li><strong>${id}</strong><ul class="padding-0 padding-left-25px">`;
                html += `<li><%= t('hwinfo.puavopkg.version') %>: ${pkg.version}</li>`;
                html += `<li><%= t('hwinfo.puavopkg.package_id') %>: ${pkg["package-id"]}</li>`;
                html += `<li><%= t('hwinfo.puavopkg.upstream_hash') %>: ${pkg.upstream_pack === null ? "-" : pkg.upstream_pack}</li>`;
                html += `</ul></li>`

                // Include the hashes and IDs in the plain text string, so the column
                // is substring-searchable in filters. Not pretty, but it works.
                text += `${id} ${pkg["package-id"]} ${pkg.upstream_pack} `;
            }

            html += "</ul>";

            return [html, text, text];
        },
    },

    purchase_date: {
        key: "purchase_date",
        title: "<%= t('columns.devices.purchase_date') %>",
        type: ST.ColumnType.UNIXTIME,
        transform: entry => doTimestamp(entry.purchase_date),
    },

    purchase_loc: {
        key: "purchase_loc",
        title: "<%= t('columns.devices.purchase_loc') %>",
        type: ST.ColumnType.STRING,
    },

    purchase_support: {
        key: "purchase_support",
        title: "<%= t('columns.devices.purchase_support') %>",
        type: ST.ColumnType.STRING,
        export_name: "support_contract",
    },

    purchase_url: {
        key: "purchase_url",
        title: "<%= t('columns.devices.purchase_url') %>",
        type: ST.ColumnType.STRING,
    },

    purchase_warranty: {
        key: "purchase_warranty",
        title: "<%= t('columns.devices.purchase_warranty') %>",
        type: ST.ColumnType.UNIXTIME,
        transform: entry => doTimestamp(entry.purchase_warranty),
        export_name: "warranty_ends",
    },

    ram: {
        key: "ram",
        title: "<%= t('columns.devices.hwinfo_ram_size') %>",
        type: ST.ColumnType.NUMERIC,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT | ST.ColumnFlag.F_STORAGE,
        transform: entry => doStorage(entry.ram),
    },

<% unless @is_servers %>
    reset_from: {
        key: "reset_from",
        title: "<%= t('columns.devices.reset_from') %>",
        type: ST.ColumnType.STRING,
    },

    reset_operation: {
        key: "reset_operation",
        title: "<%= t('columns.devices.reset_operation') %>",
        type: ST.ColumnType.STRING,
    },

    reset_pin: {
        key: "reset_pin",
        title: "<%= t('columns.devices.reset_pin') %>",
        type: ST.ColumnType.NUMERIC,
    },

    reset_time: {
        key: "reset_time",
        title: "<%= t('columns.devices.reset_time') %>",
        type: ST.ColumnType.UNIXTIME,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT,
    },

    reset_fulfilled: {
        key: "reset_time",
        title: "<%= t('columns.devices.reset_fulfilled') %>",
        type: ST.ColumnType.UNIXTIME,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT,
    },
<% end %>

    serial: {
        key: "serial",
        title: "<%= t('columns.devices.serial') %>",
        type: ST.ColumnType.STRING,
    },

    status: {
        key: "status",
        title: "<%= t('columns.devices.status') %>",
        type: ST.ColumnType.STRING,
    },

    tags: {
        key: "tags",
        title: "<%= t('columns.devices.tags') %>",
        type: ST.ColumnType.STRING,
        flags: ST.ColumnFlag.ARRAY,
    },

    type: {
        key: "type",
        title: "<%= t('columns.devices.type') %>",
        type: ST.ColumnType.STRING,
    },

    tz: {
        key: "tz",
        title: "<%= t('columns.devices.tz') %>",
        type: ST.ColumnType.STRING,
        flags: ST.ColumnFlag.F_NULLABLE,
    },

    uptime: {
        key: "uptime",
        title: "<%= t('columns.devices.uptime') %>",
        type: ST.ColumnType.NUMERIC,
        flags: ST.ColumnFlag.DESCENDING_DEFAULT,
        transform: entry => {
            return [`${entry.uptime} (${formatUptime(entry.uptime)})`, entry.uptime, entry.uptime];
        },
    },

    wifi: {
        key: "wifi",
        title: "<%= t('columns.devices.wifi') %>",
        type: ST.ColumnType.STRING,
    },

    windows_license: {
        key: "windows_license",
        title: "<%= t('columns.devices.has_windows_license') %>",
        type: ST.ColumnType.BOOL,
    },

    windows_installed: {
        key: "windows_installed",
        title: "<%= t('columns.devices.windows_installed') %>",
        type: ST.ColumnType.BOOL,
    },

    xrandr: {
        key: "xrandr",
        title: "<%= t('columns.devices.xrandr') %>",
        type: ST.ColumnType.STRING,
        flags: ST.ColumnFlag.ARRAY,
    },
};

// The default order for columns
const COLUMN_ORDER = [
<% if @is_organisation %>
    "school",
    "hw_time",
<% end %>
    "id",
    "hn",
    "display_name",
<% if !@is_organisation || @is_servers %>
    "hw_time",
<% end %>
<% if @is_servers %>
    "schools",
    "available_images",
    "location",
    "mac",
<% end %>
    "type",
    "tags",
<% unless @is_servers %>
    "mac",
<% end %>
    "mfer",
    "model",
    "serial",
    "status",
    "image",
    "current_image",
    "image_series",
    "krn_args",
    "krn_ver",
    "created",
    "modified",
    "conf",
    "user",
    "ram",
    "hd",
    "hd_ssd",
    "have_smart",
    "df_home",
    "df_imageoverlays",
    "df_images",
    "df_state",
    "df_tmp",
    "cpu",
    "bios_vendor",
    "bios_version",
    "bios_date",
    "bat_vendor",
    "bat_serial",
    "bat_cap",
    "bat_pcnt",
    "bat_volts",
    "wifi",
    "xrandr",
    "monitors_xml",
    "lspci",
    "lsusb",
    "abitti_version",
    "windows_license",
    "windows_installed",
    "purchase_date",
    "purchase_warranty",
    "purchase_loc",
    "purchase_url",
    "purchase_support",
<% unless @is_servers %>
    "location",
<% end %>
    "loc_lat",
    "loc_lon",
    "tz",
    "default_printer",
    "printer_uri",
    "audio_src",
    "audio_sink",
<% unless @is_servers %>
    "allow_guest",
    "personal_admin",
    "personal_device",
    "auto_updates",
    "autopower_mode",
    "autopower_on",
    "autopower_off",
    "reset_from",
    "reset_time",
    "reset_fulfilled",
    "reset_pin",
    "reset_operation",
<% end %>
    "ll_uid",
    "ll_time",
    "uptime",
    "puavopkgs",
    "desc",
    "notes",
<% unless @is_servers %>
    "expiration",
<% end %>
];

const FILTER_PRESETS = [
    // Traditional
    [
        {
            title: "<%= t('devices.index.filters.uses_stretch') %>",
            id: "uses_stretch",
            filters: [
                [1,"current_image","=","stretch"],
            ],
        },

        {
            title: "<%= t('devices.index.filters.uses_buster') %>",
            id: "uses_buster",
            filters: [
                [1,"current_image","=","buster"],
            ],
        },

        {
            title: "<%= t('devices.index.filters.uses_bullseye') %>",
            id: "uses_bullseye",
            filters: [
                [1,"current_image","=","bullseye"],
            ],
        },

        {
            title: "<%= t('devices.index.filters.have_monitors_xml') %>",
            id: "has_monitors_xml",
            filters: [
                [1,"monitors_xml","!=","^$"],
            ],
        },

        {
            title: "<%= t('devices.index.filters.have_xrandr') %>",
            id: "has_xrandr",
            filters: [
                [1,"xrandr","!=","^$"],
            ],
        },

        {
            title: "<%= t('devices.index.filters.have_hwinfo') %>",
            id: "has_hwinfo",
            filters: [
                [1,"hw_time",">=","2010"],
            ],
        },

        {
            title: "<%= t('devices.index.filters.has_primary_user') %>",
            id: "has_primary_user",
            filters: [
                [1,"user","!=","^$"],
            ],
        },

        {
            title: "<%= t('devices.index.filters.has_invalid_primary_user') %>",
            id: "has_invalid_primary_user",
            filters: [
                [1,"user","=","puavoId="],
            ],
        },

        {
            title: "<%= t('devices.index.filters.hdd_500g') %>",
            id: "hdd_500g",
            filters: [
                [1,"hd",">=","500G"],
            ],
        },

        {
            title: "<%= t('devices.index.filters.ram_atleast_16g') %>",
            id: "ram_atleast_16g",
            filters: [
                [1,"ram",">=","16G"],
            ],
        },

        {
            title: "<%= t('devices.index.filters.ram_lt_4g') %>",
            id: "ram_lt_4g",
            filters: [
                [1,"ram","<","4G"],
            ],
        },

        {
            title: "<%= t('devices.index.filters.poor_battery') %>",
            id: "poor_battery",
            filters: [
                [1,"bat_cap","<=","70"],
            ],
        },
    ],

    // Advanced
    {
        uses_stretch: {
            title: "<%= t('devices.index.filters.uses_stretch') %>",
            filter: "current_image = /stretch/",
        },

        uses_buster: {
            title: "<%= t('devices.index.filters.uses_buster') %>",
            filter: "current_image = /buster/",
        },

        uses_bullseye: {
            title: "<%= t('devices.index.filters.uses_bullseye') %>",
            filter: "current_image = /bullseye/",
        },

        has_monitors_xml: {
            title: "<%= t('devices.index.filters.have_monitors_xml') %>",
            filter: "monitors_xml",
        },

        has_xrandr: {
            title: "<%= t('devices.index.filters.have_xrandr') %>",
            filter: `xrandr != ""`
        },

        has_hwinfo: {
            title: "<%= t('devices.index.filters.have_hwinfo') %>",
            filter: "hw_time > 2010"
        },

        maybe_unused: {
            title: "<%= t('devices.index.filters.maybe_unused') %>",
            filter: `hw_time && hw_time < -1y`,
        },

        has_primary_user: {
            title: "<%= t('devices.index.filters.has_primary_user') %>",
            filter: `user && user != /^puavoId/`,
        },

        has_invalid_primary_user: {
            title: "<%= t('devices.index.filters.has_invalid_primary_user') %>",
            filter: `user && user = /^puavoId/`,
        },

        hdd_500g: {
            title: "<%= t('devices.index.filters.hdd_500g') %>",
            filter: `hd >= 500G`,
        },

        ram_atleast_16g: {
            title: "<%= t('devices.index.filters.ram_atleast_16g') %>",
            filter: `ram >= 16G`,
        },

        ram_lt_4g: {
            title: "<%= t('devices.index.filters.ram_lt_4g') %>",
            filter: `ram < 4G`,
        },

        home_5g: {
            title: "<%= t('devices.index.filters.home_5g') %>",
            filter: `df_home <= 5G`,
        },

        poor_battery: {
            title: "<%= t('devices.index.filters.poor_battery') %>",
            filter: `bat_cap <= 70%`,
        },
    }
];

<% if @permit_device_mass_set_db_fields %>
// Mass operation: set database field value
class MassDeviceSetFieldValue extends ST.MassOperation {
    constructor(parent, container)
    {
        super(parent, container);

        // NOTE: These entries are not listed in this order in the combo. They're sorted alphabetically by title.
        this.items = [
            { title: "<%= t('devices.index.mass_operations.set_field.desktop_image') %>", key: "image", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.kernel_args') %>", key: "kernelargs", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.kernel_version') %>", key: "kernelversion", type: "s" },
<% if @permit_device_mass_edit_puavoconf %>
            { title: "<%= t('devices.index.mass_operations.set_field.puavoconf_replace') %>", key: "puavoconf", type: "json" },
<% end %>
<% if @permit_device_mass_tag_editor %>
            { title: "<%= t('devices.index.mass_operations.set_field.tags_replace') %>", key: "tags", type: "s" },
<% end %>
            { title: "<%= t('devices.index.mass_operations.set_field.manufacturer') %>", key: "manufacturer", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.model') %>", key: "model", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.serial') %>", key: "serial", type: "s" },
<% unless @is_servers %>
            { title: "<%= t('devices.index.mass_operations.set_field.primary_user') %>", key: "primary_user", type: "s" },
<% end %>
            { title: "<%= t('devices.index.mass_operations.set_field.description') %>", key: "description", type: "ms" },
            { title: "<%= t('devices.index.mass_operations.set_field.status') %>", key: "status", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.location') %>", key: "location", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.latitude') %>", key: "latitude", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.longitude') %>", key: "longitude", type: "s" },
<% unless @is_servers %>
            { title: "<%= t('devices.index.mass_operations.set_field.allow_guest') %>", key: "allow_guest", type: "tfd" },
            { title: "<%= t('devices.index.mass_operations.set_field.automatic_updates') %>", key: "automatic_updates", type: "tfd" },
            { title: "<%= t('devices.index.mass_operations.set_field.personal_device') %>", key: "personal_device", type: "tfd" },
            { title: "<%= t('devices.index.mass_operations.set_field.personally_administered') %>", key: "personally_administered", type: "tfd" },
            { title: "<%= t('devices.index.mass_operations.set_field.automatic_poweroff') %>", key: "automatic_poweroff", type: "autopoweroff" },
            { title: "<%= t('devices.index.mass_operations.set_field.daytime_start') %>", key: "day_start", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.daytime_end') %>", key: "day_end", type: "s" },
<% end %>
            { title: "<%= t('devices.index.mass_operations.set_field.audio_source') %>", key: "audio_source", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.audio_sink') %>", key: "audio_sink", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.printer_uri') %>", key: "printer_uri", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.default_printer') %>", key: "default_printer", type: "s" },
            { title: "<%= t('devices.index.mass_operations.set_field.image_source_url') %>", key: "image_source_url", type: "image_source_url" },
            { title: "<%= t('devices.index.mass_operations.set_field.xrandr') %>", key: "xrandr", type: "xrandr" },
            { title: "<%= t('devices.index.mass_operations.set_field.monitors_xml') %>", key: "monitors_xml", type: "xml" },
        ];

        this.currentUI = -1;
    }

    buildInterface()
    {
        let html = "";

        html +=
`<table class="settingsChild"><tr>
<th><label for="field_name"><%= t('devices.index.mass_operations.set_field.field') %></label></th>
<td><select id="field_name">`;

        // List the possible fields in alphabetical order
        let sorted = [];

        for (let i = 0; i < this.items.length; i++)
            sorted.push([this.items[i].title.toLowerCase(), i]);

        sorted.sort((a, b) => a[0].localeCompare(b[0]));

        for (const s of sorted) {
            const item = this.items[s[1]];

            html += `<option value="${s[1]}">${item.title}</option>`;
        }

        html +=
`</select></td></tr>
<tr>
<th><label for="field_value"><%= t('devices.index.mass_operations.set_field.value') %></label></th>
<td><div id="uiChild"></div></td></tr></table>`;

        this.container.innerHTML = html;

        // Initial selection and UI
        this.currentUI = sorted[0][1];
        this.container.querySelector("select#field_name").value = this.currentUI;
        this.buildChildUI(this.currentUI);

        this.container.querySelector("select#field_name").addEventListener("change", e => {
            this.currentUI = parseInt(e.target.value, 10);
            this.buildChildUI(this.currentUI);
        });
    }

    canProceed()
    {
        if (this.items[this.currentUI].type == "json") {
            // Does the textarea contain valid JSON? This is the only time we can validate the value.
            const v = this.getValue();

            if (v.length == 0) {
                // Empty strings are not valid JSON, but we still let them through
                // because then the database field can be cleared (ie. set to null)
                return true;
            }

            try {
                const o = JSON.parse(v);

                if (!o || typeof(o) !== "object")
                    throw new Error("");        // just display the error message below
            } catch (e) {
                window.alert("<%= t('devices.index.mass_operations.set_field.invalid_json') %>");
                return false;
            }
        } else if (this.items[this.currentUI].type == "xml") {
            // Validate the monitors XML configuration
            const value = this.getValue();

            if (value == "") {
                // Permit the configuration to be cleared
                return true;
            }

            try {
                const result = new DOMParser().parseFromString(value, "application/xml");

                // Of course this isn't straightforward...
                if (result.getElementsByTagName("parsererror").length != 0)
                    throw new Error("invalid XML");
            } catch (e) {
                console.log(e);
                window.alert("<%= t('devices.index.mass_operations.set_field.invalid_xml') %>");
                return false;
            }
        }

        return true;
    }

    buildChildUI(index)
    {
        const stringWarning = `<p class="validityWarning margin-0"><%= t('devices.index.mass_operations.set_field.validity_warning_string') %></p>`,
              jsonWarning = `<p class="validityWarning margin-0"><%= t('devices.index.mass_operations.set_field.validity_warning_json') %></p>`,
              xmlWarning = `<p class="validityWarning margin-0"><%= t('devices.index.mass_operations.set_field.validity_warning_xml') %></p>`,
              xrandrWarning = `<p class="validityWarning margin-0"><%= t('devices.index.mass_operations.set_field.validity_warning_xrandr') %></p>`,
              imageSourceWarning = `<p class="validityWarning margin-0"><%= t('devices.index.mass_operations.set_field.validity_warning_image_source') %></p>`;

        const item = this.items[index];

        let ui = "";

        switch (item.type) {
            case "s":           // String
            default:
                ui = `<input type="text" id="field_value" size="40">${stringWarning}`;
                break;

            case "ms":          // Multiline string
                ui = `<textarea id="field_value" rows="5" cols="80"></textarea>${stringWarning}`;
                break;

            case "json":        // Same as multiline string, but displays a note about the JSON
                ui = `<textarea id="field_value" rows="5" cols="80"></textarea>${jsonWarning}`;
                break;

            case "xml":         // Same as multiline string, but the contents are validated as XML
                ui = `<textarea id="field_value" rows="5" cols="80"></textarea>${xmlWarning}`;
                break;

            case "tfd":         // Tri-state true/false/default "boolean"
                ui =
`<div class="flex-rows">
<label><input type="radio" name="value-radio" id="value-radio-default" checked><%= t('devices.index.mass_operations.set_field.default') %></label>
<label><input type="radio" name="value-radio" id="value-radio-yes"><%= t('devices.index.mass_operations.set_field.yes') %></label>
<label><input type="radio" name="value-radio" id="value-radio-no"><%= t('devices.index.mass_operations.set_field.no') %></label>
</div>`;
                break;

            case "autopoweroff":    // Custom UI for the automatic poweroff setting
                ui =
`<div class="flex-rows">
<label><input type="radio" name="value-radio" id="value-radio-default" checked><%= t('devices.index.mass_operations.set_field.default') %></label>
<label><input type="radio" name="value-radio" id="value-radio-off"><%= t('devices.index.mass_operations.set_field.off') %></label>
<label><input type="radio" name="value-radio" id="value-radio-custom"><%= t('devices.index.mass_operations.set_field.custom') %></label>
</div>`;
                break;

            case "xrandr":          // Multiline Xrandr string
                ui = `<textarea id="field_value" rows="5" cols="80"></textarea>${xrandrWarning}`;
                break;

            case "image_source_url":    // Multiline image sources string (one URL per line)
                ui = `<textarea id="field_value" rows="5" cols="80"></textarea>${imageSourceWarning}`;
                break;
        }

        this.container.querySelector("div#uiChild").innerHTML = ui;
    }

    getOperationParameters()
    {
        return {
            field: this.items[this.currentUI].key,
            value: this.getValue()
        };
    }

    prepareItem(item)
    {
        return { state: "ready" };
    }

    getValue()
    {
        const ui = this.container.querySelector("div#uiChild");

        // Retrieve the value. There are no event handlers, so dig it from
        // the DOM directly.
        switch (this.items[this.currentUI].type) {
            case "s":
            default:
                return ui.querySelector("input").value;

            case "ms":
            case "json":
            case "xml":
                return ui.querySelector("textarea").value;

            case "xrandr":
            case "image_source_url":
                return ui.querySelector("textarea").value.split("\n").map(r => r.trim());

            case "tfd":
                if (ui.querySelector("input#value-radio-yes").checked)
                    return 1;
                else if (ui.querySelector("input#value-radio-no").checked)
                    return 0;

                // "default"
                return -1;

            case "autopoweroff":
                if (ui.querySelector("input#value-radio-custom").checked)
                    return "custom";
                else if (ui.querySelector("input#value-radio-off").checked)
                    return "off";

                return "default";
        }
    }
};
<% end %>

<% unless @is_servers %>
<% if @permit_device_mass_edit_purchase_info %>
// Mass operation: edit purchase and warranty information
class MassDevicePurchaseInfo extends ST.MassOperation {
    constructor(parent, container)
    {
        super(parent, container);
    }

    buildInterface()
    {
        this.container.innerHTML =
`<p class="margin-0 padding-10px"><%= t('devices.index.mass_operations.purchase_info.validity_warning').html_safe %></p>
<table class="settingsChild">
    <tr>
        <th><label><input type="checkbox" id="purchase_date"><%= t('activeldap.attributes.device.puavoPurchaseDate') %>:</label></th>
        <td><input type="date" id="purchase_date_value" min="2000-01-01" max="2050-12-31"></td>
    </tr>
    <tr>
        <th><label><input type="checkbox" id="warranty_end_date"><%= t('activeldap.attributes.device.puavoWarrantyEndDate') %>:</label></th>
        <td><input type="date" id="warranty_end_date_value" min="2000-01-01" max="2050-12-31"></td>
    </tr>
    <tr>
        <th><label><input type="checkbox" id="purchase_location"><%= t('activeldap.attributes.device.puavoPurchaseLocation') %>:</label></th>
        <td><input type="text" id="purchase_location_value"></td>
    </tr>
    <tr>
        <th><label><input type="checkbox" id="purchase_url"><%= t('activeldap.attributes.device.puavoPurchaseURL') %>:</label></th>
        <td><input type="text" id="purchase_url_value"></td>
    </tr>
    <tr>
        <th><label><input type="checkbox" id="support_contract"><%= t('activeldap.attributes.device.puavoSupportContract') %>:</label></th>
        <td><input type="text" id="support_contract_value"></td>
    </tr>
</table>`;

        // Current form data. If "checked" is true, then the related checkbox is checked. "value" contains the current
        // value (which can be NULL).
        this.data = {
            purchase_date: {
                checked: false,
                value: null
            },

            warranty_end_date: {
                checked: false,
                value: null
            },

            purchase_location: {
                checked: false,
                value: null
            },

            purchase_url: {
                checked: false,
                value: null
            },

            support_contract: {
                checked: false,
                value: null
            }
        };

        // Setup event handling
        this.container.querySelector("#purchase_date").addEventListener("click", e => this.checkboxClicked(e));
        this.container.querySelector("#warranty_end_date").addEventListener("click", e => this.checkboxClicked(e));
        this.container.querySelector("#purchase_location").addEventListener("click", e => this.checkboxClicked(e));
        this.container.querySelector("#purchase_url").addEventListener("click", e => this.checkboxClicked(e));
        this.container.querySelector("#support_contract").addEventListener("click", e => this.checkboxClicked(e));
        this.container.querySelector("#purchase_date_value").addEventListener("input", e => this.dateChanged(e));
        this.container.querySelector("#warranty_end_date_value").addEventListener("input", e => this.dateChanged(e));
        this.container.querySelector("#purchase_location_value").addEventListener("input", e => this.textChanged(e));
        this.container.querySelector("#purchase_url_value").addEventListener("input", e => this.textChanged(e));
        this.container.querySelector("#support_contract_value").addEventListener("input", e => this.textChanged(e));
    }

    // A checkbox has been clicked
    checkboxClicked(e)
    {
        this.data[e.target.id].checked = e.target.checked;
    }

    // A text field has been edited
    textChanged(e)
    {
        const key = e.target.id.replace(/_value$/, '');

        this.data[key].value = e.target.value.trim();

        if (this.data[key].value.length == 0)
            this.data[key].value = null;
    }

    // A date value has changed
    dateChanged(e)
    {
        const key = e.target.id.replace(/_value$/, '');

        this.data[key].value = e.target.value;

        if (this.data[key].value.length == 0)
            this.data[key].value = null;
    }

    // I'm not sure if this function is really needed, but
    // JS's type coercions and comparisons are horrible and
    // I will not take any chances with them
    hasChanged(a, b)
    {
        let a2, b2;

        if (a === undefined || a === null || a == "")
            a2 = null;
        else a2 = a;

        if (b === undefined || b === null || b == "")
            b2 = null;
        else b2 = b;

        return a2 !== b2;
    }

    start()
    {
        for (const i of this.container.querySelectorAll("input"))
            i.disabled = true;
    }

    finish()
    {
        for (const i of this.container.querySelectorAll("input"))
            i.disabled = false;
    }

    getOperationParameters()
    {
        let values = {};

        // Only send the checked values
        for (const key of Object.keys(this.data))
            if (this.data[key].checked)
                values[key] = this.data[key].value;

        return values;
    }

    prepareItem(item)
    {
        // Find all changed values
        let changes = [];

        if (this.data["purchase_date"].checked) {
            const date = item.purchase_date[ST.INDEX_EXISTS] ? item.purchase_date[ST.INDEX_DISPLAYABLE] : null;

            if (this.hasChanged(date, this.data["purchase_date"].value))
                changes.push("purchase_date");
        }

        if (this.data["warranty_end_date"].checked) {
            const date = item.purchase_warranty[ST.INDEX_EXISTS] ? item.purchase_warranty[ST.INDEX_DISPLAYABLE] : null;

            if (this.hasChanged(date, this.data["warranty_end_date"].value))
                changes.push("warranty_end_date");
        }

        if (this.data["purchase_location"].checked && this.hasChanged(item.purchase_loc[ST.INDEX_DISPLAYABLE], this.data["purchase_location"].value))
            changes.push("purchase_location");

        if (this.data["purchase_url"].checked && this.hasChanged(item.purchase_url[ST.INDEX_DISPLAYABLE], this.data["purchase_url"].value))
            changes.push("purchase_url");

        if (this.data["support_contract"].checked && this.hasChanged(item.purchase_support[ST.INDEX_DISPLAYABLE], this.data["support_contract"].value))
            changes.push("support_contract");

        if (changes.length == 0) {
            // No changes to this item
            return { state: "skip" };
        }

        return {
            state: "ready",
            data: changes
        };
    }
};
<% end %>
<% end %>

<% if @permit_device_mass_edit_puavoconf %>
// Mass operation: puavo-conf editing. Not as pretty as the interactive puavo-conf editor, but I can't cram it in this space.
// Or maybe I can? Who knows...
class MassDevicePuavoconfEditor extends ST.MassOperation {
    constructor(parent, container)
    {
        super(parent, container);

        // Same settings as below
        this.key = "";
        this.value = null;
        this.type = "string";
        this.action = "add";
    }

    buildInterface()
    {
        this.container.innerHTML =
`<p class="margin-0 padding-0"><%= t('devices.index.mass_operations.puavoconf.json_warning').html_safe %></p>
<table class="settingsChild">
<tr>
    <th><label for="pc_key"><%= t('devices.index.mass_operations.puavoconf.key') %></label></th>
    <td><input type="text" id="pc_key" size="50"></td>
</tr>
<tr>
    <th><label for="pc_value"><%= t('devices.index.mass_operations.puavoconf.value') %></label></th>
    <td>
        <input type="text" id="pc_value" size="50">
        <p class="validityWarning margin-0 padding-0"><%= t('devices.index.mass_operations.puavoconf.validity_warning') %></p>
    </td>
</tr>
<tr>
    <th><%= t('devices.index.mass_operations.puavoconf.type') %></th>
    <td>
        <div class="flex-columns gap-10px">
            <label><input type="radio" name="pc_type" id="type_string" checked><%= t('devices.index.mass_operations.puavoconf.type_string') %></label>
            <label><input type="radio" name="pc_type" id="type_int"><%= t('devices.index.mass_operations.puavoconf.type_int') %></label>
            <label><input type="radio" name="pc_type" id="type_bool"><%= t('devices.index.mass_operations.puavoconf.type_bool') %></label>
        </div>
    </td>
</tr>
<tr>
    <th><%= t('devices.index.mass_operations.puavoconf.action') %></th>
    <td>
        <div class="flex-rows">
            <label><input type="radio" name="pc_action" id="action_add" checked><%= t('devices.index.mass_operations.puavoconf.add') %></label>
            <label><input type="radio" name="pc_action" id="action_remove"><%= t('devices.index.mass_operations.puavoconf.remove') %></label>
        </div>
    </td>
</tr>
</table>`;

        // Setup event handling
        this.container.querySelector("#pc_key").addEventListener("input", e => this.key = e.target.value.trim());
        this.container.querySelector("#pc_value").addEventListener("input", e => this.value = e.target.value.trim());
        this.container.querySelector("#type_string").addEventListener("click", () => this.type = "string");
        this.container.querySelector("#type_int").addEventListener("click", () => this.type = "int");
        this.container.querySelector("#type_bool").addEventListener("click", () => this.type = "bool");
        this.container.querySelector("#action_add").addEventListener("click", () => this.action = "add");
        this.container.querySelector("#action_remove").addEventListener("click", () => this.action = "remove");
    }

    canProceed()
    {
        // We only care about the key's length. The value can be empty.
        if (this.key.length == 0) {
            window.alert("<%= t('devices.index.mass_operations.puavoconf.empty_key') %>");
            return false;
        }

        return true;
    }

    getOperationParameters()
    {
        return {
            key: this.key,
            value: this.value,
            type: this.type,
            action: this.action
        };
    }

    prepareItem(item)
    {
        return { state: "ready" };
    }
};
<% end %>

<% if @permit_device_mass_tag_editor %>
// Mass tags editor
class MassDeviceTagsEditor extends ST.MassOperation {
    constructor(parent, container)
    {
        super(parent, container);

        this.tags = "";
        this.action = "add";
    }

    buildInterface()
    {
        this.container.innerHTML =
`<p class="margin-0 padding-0"><%= t('devices.index.mass_operations.tags.warning').html_safe %></p>
<table class="settingsChild">
<tr>
    <th><label for="tags_tag"><%= t('devices.index.mass_operations.tags.tag') %></label></th>
    <td>
        <input type="text" id="tags_tag" size="50">
        <p class="validityWarning margin-0 padding-0"><%= t('devices.index.mass_operations.tags.help') %></p>
    </td>
</tr>
<tr>
    <th><%= t('devices.index.mass_operations.tags.action') %></th>
    <td>
        <div class="flex-rows">
            <label><input type="radio" name="tags_action" id="action_add" checked><%= t('devices.index.mass_operations.tags.add') %></label>
            <label><input type="radio" name="tags_action" id="action_remove"><%= t('devices.index.mass_operations.tags.remove') %></label>
        </div>
    </td>
</tr>
</table>`;

        // Setup event handling
        this.container.querySelector("#tags_tag").addEventListener("input", e => this.tags = e.target.value.trim());
        this.container.querySelector("#action_add").addEventListener("click", () => this.action = "add");
        this.container.querySelector("#action_remove").addEventListener("click", () => this.action = "remove");
    }

    canProceed()
    {
        if (this.tags.length == 0) {
            window.alert("<%= t('devices.index.mass_operations.tags.empty_tag') %>");
            return false;
        }

        return true;
    }

    getOperationParameters()
    {
        return {
            tags: this.tags,
            type: this.type,
            action: this.action
        };
    }

    prepareItem(item)
    {
        return { state: "ready" };
    }
};
<% end %>

<% unless @is_servers %>
<% unless @school_list.empty? %>
// Mass tool: move device to another school
class MassDeviceChangeSchool extends ST.MassOperation {
    constructor(parent, container)
    {
        super(parent, container);
        this.newSchoolDN = null;
        this.newSchoolID = -1;
    }

    buildInterface()
    {
        const html =
        `<label><%= t('devices.index.mass_operations.change_school.school_title') %><select id="new_school" class="margin-left-5px">
<% @school_list.each do |s| %>
            <option value="<%= s[0] %>"><%= s[1] %> (<%= s[2] %>)</option>
<% end%>
        </select></label>`;

        this.container.innerHTML = html;

        this.newSchoolDN = "<%= @school_list.first[0] %>";
        this.newSchoolID = this.idFromDN(this.newSchoolDN);

        this.container.querySelector("#new_school")?.addEventListener("change", e => {
            this.newSchoolDN = e.target.value
            this.newSchoolID = this.idFromDN(this.newSchoolDN);
        });
    }

    canProceed()
    {
        return true;
    }

    start()
    {
        this.container.querySelector("#new_school").disabled = true;
    }

    finish()
    {
        this.container.querySelector("#new_school").disabled = false;
    }

    getOperationParameters()
    {
        return {
            school_dn: this.newSchoolDN
        };
    }

    prepareItem(item)
    {
        if (item.school_id == this.newSchoolID) {
            // The device is already in the target school. This can only happen in the
            // organisation-level "all devices" table, because ordinary school device
            // tables won't list devices that are not in the current school.
            return { state: "skip" };
        }

        return { state: "ready" };
    }

    idFromDN(dn)
    {
        const match = dn.match(/^puavoId=(\d+),ou=/);

        return (match === null) ? -1 : parseInt(match[1], 10);
    }
};
<% end %>

<% if @permit_device_reset && @permit_device_mass_reset %>
// Mass operation: set or clear the reset mode
class MassDeviceReset extends ST.MassOperation {
    constructor(parent, container)
    {
        super(parent, container);
        this.reset = true;
    }

    buildInterface()
    {
        this.container.innerHTML =
`<table class="settingsChild">
<tr>
    <th><%= t('devices.index.mass_operations.reset.action') %></th>
    <td>
        <div class="flex-rows">
            <label><input type="radio" name="action" id="set" checked><%= t('devices.index.mass_operations.reset.reset') %></label>
            <label><input type="radio" name="action" id="clear"><%= t('devices.index.mass_operations.reset.clear') %></label>
        </div>
    </td>
</tr>
</table>`;

        this.container.querySelector("#set").addEventListener("click", () => this.reset = true);
        this.container.querySelector("#clear").addEventListener("click", () => this.reset = false);
    }

    start()
    {
        for (const i of this.container.querySelectorAll("input"))
            i.disabled = true;
    }

    finish()
    {
        for (const i of this.container.querySelectorAll("input"))
            i.disabled = false;
    }

    getOperationParameters()
    {
        return {
            reset: this.reset,
        };
    }

    prepareItem(item)
    {
        if (item.type[ST.INDEX_FILTERABLE] !== "laptop") {
            // Only laptops can be mass reset
            return { state: "error", message: "<%= t('devices.index.mass_operations.reset.not_a_laptop') %>" };
        }

        // All "reset" members will be omitted if the device is not in reset mode
        if (item.reset_from[ST.INDEX_EXISTS] === this.reset) {
            console.log(`Device ${item.id[ST.INDEX_FILTERABLE]} is already in the desired reset state, skipping`);
            return { state: "skip" };
        }

        return { state: "ready" };
    }
};
<% end %>

<% if @permit_device_deletion && @permit_device_mass_deletion %>
// Mass operation: delete device
class MassDeviceDeletion extends ST.MassOperation {
    constructor(parent, container)
    {
        super(parent, container);
    }

    prepareItem(item)
    {
        return { state: "ready" };
    }
};
<% end %>

<% if @permit_device_mass_edit_expiration_times %>
const pad = (n, count=2) => n.toString().padStart(count, "0")

// Mass operation: set or clear expiration times. Uses the translation strings from the users page,
// as the strings are 100% identical and I don't want to duplicate them (even if there are only
// a few of them).
class MassSetExpirationTimes extends ST.MassOperation {
    constructor(parent, container)
    {
        super(parent, container);
        this.expirationTime = null;
    }

    buildInterface()
    {
        this.container.innerHTML =
`<table class="settingsChild">
<tr>
    <th><%= t('users.index.mass_operations.set_expiration_times.set_to') %></th>
    <td>
        <div class="flex-cols gap-5px">
            <input type="datetime-local" id="time" autocomplete="off" step="1">

            <button type="button" id="clear"><%= t('expiration_times.clear') %></button>

            <select id="preset" autocomplete="off">
            <option value="" disabled selected hidden><%= t('expiration_times.presets') %></option>
            <%= expiration_time_presets %>
            </select>
        </div>
    </td>
</tr>`;

        this.container.querySelector("input#time").addEventListener("change", e => this.onChangeTime(e));
        this.container.querySelector("button#clear").addEventListener("click", () => this.onClear());
        this.container.querySelector("select#preset").addEventListener("change", e => this.onSelectPreset(e));
    }

    onChangeTime(e)
    {
        const d = e.target.value;

        if (d === null) {
            this.expirationTime = null;
            return;
        }

        // Local time -> UTC time
        const offset = new Date().getTimezoneOffset(),
              offsetString = `${offset < 0 ? "+" : "-"}${pad(Math.abs(offset) / 60, 2)}:${pad(Math.abs(offset) % 60, 2)}`;

        this.expirationTime = new Date(e.target.value + offsetString);
        console.log(this.expirationTime);
    }

    onClear()
    {
        this.container.querySelector("input#time").value = null;
        this.expirationTime = null;
    }

    onSelectPreset(e)
    {
        this.expirationTime = new Date(new Date().getTime() + (parseInt(e.target.value, 10) * 1000));
        this.setTime(this.expirationTime);
        e.target.value = "";
    }

    setTime(date)
    {
        this.container.querySelector("input#time").value =
            pad(date.getFullYear(), 4) + "-" +
            pad(date.getMonth() + 1) + "-" +
            pad(date.getDate()) + "T" +
            pad(date.getHours()) + ":" +
            pad(date.getMinutes()) + ":" +
            pad(date.getSeconds());
    }

    canProceed()
    {
        return true;
    }

    start()
    {
        for (const i of this.container.querySelectorAll("input, button, select"))
            i.disabled = true;
    }

    finish()
    {
        for (const i of this.container.querySelectorAll("input, button, select"))
            i.disabled = false;
    }

    getOperationParameters()
    {
        return {
            action: 'set_expiration_time',
            time: (this.expirationTime === null) ? null : Math.floor(this.expirationTime.getTime() / 1000)
        };
    }

    prepareItem(item)
    {
        return { state: "ready" };
    }
};
<% end %>
<% end %>

const MASS_OPERATIONS = [
<% if @permit_device_mass_set_db_fields %>
    {
        operation: "set_field",
        title: "<%= t('devices.index.mass_operations.set_field.title') %>",
        haveSettings: true,
        cls: MassDeviceSetFieldValue
    },
<% end %>
<% unless @is_servers %>
<% if @permit_device_mass_edit_purchase_info %>
    {
        operation: "purchase_info",
        title: "<%= t('devices.index.mass_operations.purchase_info.title') %>",
        haveSettings: true,
        cls: MassDevicePurchaseInfo
    },
<% end %>
<% end %>
<% if @permit_device_mass_edit_puavoconf %>
    {
        operation: "puavoconf_edit",
        title: "<%= t('devices.index.mass_operations.puavoconf.title') %>",
        haveSettings: true,
        cls: MassDevicePuavoconfEditor
    },
<% end %>
<% if @permit_device_mass_tag_editor %>
    {
        operation: "tags_edit",
        title: "<%= t('devices.index.mass_operations.tags.title') %>",
        haveSettings: true,
        cls: MassDeviceTagsEditor
    },
<% end %>
<% unless @is_servers %>
<% if @permit_device_mass_edit_expiration_times %>
    {
        operation: "set_expiration_time",
        title: "<%= t('users.index.mass_operations.set_expiration_times.title') %>",
        haveSettings: true,
        cls: MassSetExpirationTimes
    },
<% end %>
<% unless @school_list.empty? %>
    {
        operation: "change_school",
        title: "<%= t('devices.index.mass_operations.change_school.title') %>",
        haveSettings: true,
        cls: MassDeviceChangeSchool
    },
<% end %>
<% if @permit_device_reset && @permit_device_mass_reset %>
    {
        operation: "reset",
        title: "<%= t('devices.index.mass_operations.reset.title') %>",
        haveSettings: true,
        cls: MassDeviceReset
    },
<% end %>
<% if @permit_device_deletion && @permit_device_mass_deletion %>
    {
        operation: "delete",
        title: "<%= t('devices.index.mass_operations.delete.title') %>",
        cls: MassDeviceDeletion
    },
<% end %>
<% end %>
];

function userActions(item)
{
    let html = "";

<% if @is_servers %>
    const link = `/devices/servers/${item.id[ST.INDEX_FILTERABLE]}`;
<% else %>
    const link = `/devices/${item.school_id}/devices/${item.id[ST.INDEX_FILTERABLE]}`;
<% end %>

    html += `<a href="${link}/edit" class="btn"><%= t('link.edit') %></a> `;

<% if @permit_device_deletion %>
    let message = "<%= t('general_confirm') %>";

    html += `<a href="${link}" data-method="delete" data-confirm="${message}" rel="nofollow" class="btn btn-danger"><%= t('link.destroy') %></a>`
<% end %>

    return html;
}

function openCallback(item)
{
    return `/devices/${item.school_id}/devices/${item.id[ST.INDEX_DISPLAYABLE]}`;
}

function openCallbackServers(item)
{
    return `/devices/servers/${item.id[ST.INDEX_DISPLAYABLE]}`;
}

const MASS_SELECTS = [
    ["hn", "<%= t('columns.devices.hostname') %>"],
    ["id", "<%= t('columns.puavoid') %>"],
];

// Preprocesses the device data
function* preprocessDeviceData(devices)
{
    const NOW = new Date();

    for (const [_, d] of Object.entries(devices)) {
        let d2 = { ...d };

        // Inject a device expiration flag into the data, so we don't have to recalculate
        // it in several places.
        d2._has_expired = false;

        if (d2.expiration) {
            const [valid, ts] = ST.convertTimestamp(d2.expiration)

            if (valid && NOW >= new Date(d2.expiration * 1000))
                d2._has_expired = true;
        }

        yield d2;
    }
}
