Merge remote-tracking branch 'origin/main' into wilm
# Conflicts: # shopdb/data/catalog.json
This commit is contained in:
@@ -156,9 +156,34 @@ def asset_info(assetid):
|
||||
if revision is None:
|
||||
return success_response({'fields': [], 'sectioncount': 0})
|
||||
|
||||
try:
|
||||
projection = revision.payload
|
||||
except ValueError:
|
||||
# payload deliberately raises on unreadable payloadjson so a corrupt row
|
||||
# is named rather than mistaken for an opaque one. That is right for a
|
||||
# download; for an at-a-glance card it would take the whole asset page
|
||||
# down over one bad row, so the card degrades to empty instead.
|
||||
current_app.logger.warning(
|
||||
'backuprevision %s has unreadable payloadjson; info card empty',
|
||||
revision.backuprevisionid)
|
||||
return success_response({'fields': [], 'sectioncount': 0})
|
||||
|
||||
data = kind.buildinfo(
|
||||
revision.payload, assetid,
|
||||
partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES'))
|
||||
projection, assetid,
|
||||
partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES'),
|
||||
# A kind that diffs against its own previous revision needs to know
|
||||
# which revision it is looking at. Passed generically rather than
|
||||
# queried inside the kind, so the endpoint stays the single place that
|
||||
# decides "latest".
|
||||
revisionid=revision.backuprevisionid,
|
||||
# The card names the PC a snapshot came from, and chains its diff on the
|
||||
# same value. Passed from the column rather than read out of the stored
|
||||
# manifest, which carries its own copy that can disagree.
|
||||
sourcehostname=revision.sourcehostname,
|
||||
# Where the snapshot actually is. Read off the revision rather than
|
||||
# composed from the configured share root, so a snapshot written before
|
||||
# the layout changed still points at where it was really put.
|
||||
sharepath=revision.sharepath)
|
||||
data['backuprevisionid'] = revision.backuprevisionid
|
||||
data['collectedat'] = _utciso(revision.collectedat)
|
||||
return success_response(data)
|
||||
|
||||
@@ -12,6 +12,17 @@
|
||||
* JWT + a backups.* permission; an anonymous visit would only render errors.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
// The plugin's five settings had no page at all until this one: the core
|
||||
// settings catalog is a hardcoded list that a plugin cannot extend, so
|
||||
// backups_shareroot and friends were reachable only through the API or a
|
||||
// shell on the server. Paired with get_settings_cards in plugin.py, which
|
||||
// puts the card in the rail - both vanish when the plugin is disabled.
|
||||
path: 'settings/backups',
|
||||
name: 'backups-settings',
|
||||
component: () => import('./views/BackupsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'backups' }
|
||||
},
|
||||
{
|
||||
path: 'backups/asset/:assetid',
|
||||
name: 'backups-asset-history',
|
||||
|
||||
@@ -66,6 +66,16 @@
|
||||
UNC path so the tech can open it directly. -->
|
||||
<template v-if="rev.storagebackend === 'share'">
|
||||
<code class="bh-path" :title="rev.sharepath">{{ rev.sharepath }}</code>
|
||||
<!--
|
||||
A copy button rather than a file:// link. Browsers block
|
||||
navigation from an https page to file://, so a link would look
|
||||
clickable and silently do nothing - worse than no link. Copy,
|
||||
then paste into Explorer's address bar.
|
||||
-->
|
||||
<button type="button" class="bh-copy" @click="copyPath(rev.sharepath)"
|
||||
:title="'Copy path for Explorer'">
|
||||
{{ copiedPath === rev.sharepath ? 'Copied' : 'Copy path' }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button v-for="fmt in rev.formats" :key="fmt.id"
|
||||
@@ -115,6 +125,29 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import api, { settingsApi } from '@/api'
|
||||
import { formatInZone, DEFAULT_TZ } from '@/utils/datetime'
|
||||
|
||||
const copiedPath = ref('')
|
||||
|
||||
async function copyPath(path) {
|
||||
if (!path) { return }
|
||||
try {
|
||||
await navigator.clipboard.writeText(path)
|
||||
copiedPath.value = path
|
||||
setTimeout(() => { copiedPath.value = '' }, 2000)
|
||||
} catch (err) {
|
||||
// Clipboard needs a secure context and a user gesture. Both hold here, but
|
||||
// an older browser or a http:// instance will refuse - select the text so
|
||||
// the tech can copy it by hand instead of getting nothing.
|
||||
const node = document.querySelector('.bh-path')
|
||||
if (node && window.getSelection) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(node)
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const assetid = route.params.assetid
|
||||
@@ -274,6 +307,16 @@ onMounted(async () => {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.bh-path { word-break: break-all; color: var(--text-light); }
|
||||
/* Deliberately quiet: the path is the content, this is just the way to get it
|
||||
into Explorer. Sits inline after the path rather than in the actions column,
|
||||
which belongs to the revision itself. */
|
||||
.bh-copy {
|
||||
font-size: 0.72rem; margin-left: 0.4rem; padding: 0.12rem 0.4rem;
|
||||
border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-light); border-radius: 3px; cursor: pointer;
|
||||
white-space: nowrap; vertical-align: middle;
|
||||
}
|
||||
.bh-copy:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.bh-actions-col { width: 22rem; }
|
||||
|
||||
.bh-btn {
|
||||
|
||||
157
plugins/backups/frontend/views/BackupsSettings.vue
Normal file
157
plugins/backups/frontend/views/BackupsSettings.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Backups</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<h3>Collection</h3>
|
||||
<p class="setting-description">
|
||||
How often a PC attempts to capture the config it backs up. GE-Enforce runs every
|
||||
cycle, so this is what stops a bay collecting several times a day. A capture that
|
||||
finds nothing changed writes no revision - it only records that the check happened.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Interval (hours)</span>
|
||||
<input type="number" min="1" v-model="values.backups_intervalhours"
|
||||
@blur="save('backups_intervalhours')" :disabled="saving">
|
||||
<small class="input-hint">Default 24. Read by the fleet from /api/settings/public.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Share root (UNC)</span>
|
||||
<input type="text" v-model="values.backups_shareroot"
|
||||
placeholder="\\fileserver\shopfloor\backup"
|
||||
@blur="save('backups_shareroot')" :disabled="saving">
|
||||
<small class="input-hint">
|
||||
Where opaque backups (part-marker snapshots) are written by the collecting PC.
|
||||
Blank until the site sets it - no default is right at more than one site.
|
||||
Advisory: each revision records the path it was actually written to.
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<h3>Health</h3>
|
||||
<p class="setting-description">
|
||||
A backup is judged on when it was last CONFIRMED, not when it last changed. An
|
||||
unchanged config writes no revision, so a machine stable for months is healthy -
|
||||
the one to worry about is the machine whose backup stopped running.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Stale after (days)</span>
|
||||
<input type="number" min="0" v-model="values.backups_staledays"
|
||||
@blur="save('backups_staledays')" :disabled="saving">
|
||||
<small class="input-hint">
|
||||
Default 3. Drives the fleet Backup badge and the dashboard card. 0 disables both.
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<h3>Retention</h3>
|
||||
<p class="setting-description">
|
||||
Both off by default, and dedup already keeps growth low - a revision appears only
|
||||
when a config really changed. Retention exists for a value that flaps. The newest
|
||||
and oldest revision of a chain are never pruned.
|
||||
</p>
|
||||
<p class="setting-description">
|
||||
<strong>Note:</strong> pruning removes database revisions only. Files written to the
|
||||
share are not deleted, so enabling this on a share-backed kind leaves snapshot
|
||||
directories with no revision pointing at them.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Keep at most (revisions per chain)</span>
|
||||
<input type="number" min="0" v-model="values.backups_retentioncount"
|
||||
@blur="save('backups_retentioncount')" :disabled="saving">
|
||||
<small class="input-hint">0 = keep everything.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Also drop older than (days)</span>
|
||||
<input type="number" min="0" v-model="values.backups_retentiondays"
|
||||
@blur="save('backups_retentiondays')" :disabled="saving">
|
||||
<small class="input-hint">0 = no age limit.</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// The backups plugin ships five settings and, until now, no page for any of
|
||||
// them - they were reachable only through the API or a flask shell on the
|
||||
// server. The core settings catalog is hardcoded and cannot be extended by a
|
||||
// plugin, so this page is contributed the ADR-010 way: a settings card from
|
||||
// get_settings_cards plus a route here, both of which disappear when the
|
||||
// plugin is disabled.
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const KEYS = [
|
||||
'backups_intervalhours',
|
||||
'backups_shareroot',
|
||||
'backups_staledays',
|
||||
'backups_retentioncount',
|
||||
'backups_retentiondays',
|
||||
]
|
||||
|
||||
const values = reactive({})
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const response = await settingsApi.list()
|
||||
const rows = response.data?.data || response.data || []
|
||||
// The list endpoint returns every setting; pick out ours rather than
|
||||
// fetching five times.
|
||||
for (const key of KEYS) {
|
||||
const row = rows.find(item => item.key === key)
|
||||
values[key] = row ? row.value : ''
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not load backup settings')
|
||||
}
|
||||
}
|
||||
|
||||
async function save(key) {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
await settingsApi.update(key, values[key])
|
||||
success.value = 'Saved'
|
||||
setTimeout(() => { success.value = '' }, 2000)
|
||||
} catch (err) {
|
||||
error.value = apiError(err, `Could not save ${key}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -67,7 +67,10 @@ class BackupRevision(db.Model):
|
||||
# (assetid, backupkind).
|
||||
contenthash = db.Column(db.String(64), nullable=False, index=True)
|
||||
|
||||
# Populated for storagebackend='shopdb' only. MEDIUMTEXT holding serialized
|
||||
# Populated for storagebackend='shopdb', and optionally for 'share' kinds
|
||||
# that send a manifest describing what their snapshot contains (the bytes
|
||||
# still live on the share; this is the index, not the content).
|
||||
# MEDIUMTEXT holding serialized
|
||||
# JSON rather than a native JSON column, matching geenforce.manifestjson.
|
||||
# Text also means the bytes come back exactly as written, so the canonical
|
||||
# key ordering the codec produces survives the round trip - a native JSON
|
||||
@@ -105,7 +108,18 @@ class BackupRevision(db.Model):
|
||||
|
||||
@property
|
||||
def payload(self):
|
||||
"""Decoded payloadjson, or None for opaque (share-backed) kinds."""
|
||||
"""Decoded payloadjson, or None when the row stores nothing.
|
||||
|
||||
A 'shopdb' kind always has one: the parsed projection ShopDB is
|
||||
authoritative for. A 'share' kind has one only when its client sends a
|
||||
manifest describing the snapshot's contents, and None otherwise - which
|
||||
includes every share revision written before manifests existed.
|
||||
|
||||
Note the manifest is NOT covered by contenthash: the hash attests the
|
||||
bytes on the share, so two posts with identical content but different
|
||||
manifests dedup to one row. apply_collector_payload backfills a missing
|
||||
manifest on a no-op for that reason.
|
||||
"""
|
||||
if not self.payloadjson:
|
||||
return None
|
||||
try:
|
||||
|
||||
@@ -133,6 +133,31 @@ class BackupsPlugin(BasePlugin):
|
||||
panels.append(panel)
|
||||
return panels
|
||||
|
||||
def get_settings_cards(self) -> List[Dict]:
|
||||
"""ADR-010: contribute the Backups settings card.
|
||||
|
||||
The core settings catalog (settingsNav.js) is a hardcoded list a plugin
|
||||
cannot extend, so this plugin shipped five settings and no page for any
|
||||
of them - backups_shareroot and the collection interval were reachable
|
||||
only through the API or a shell on the server, which is not a
|
||||
configurable setting in any useful sense.
|
||||
|
||||
Filed under Machines because that is where the assets these back up
|
||||
live, and it puts the card next to Machine Types rather than in a
|
||||
platform group nobody visits.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'group': 'Machines',
|
||||
'to': '/settings/backups',
|
||||
'icon': 'package',
|
||||
'title': 'Backups',
|
||||
'description': 'Collection interval, share root for part-marker '
|
||||
'snapshots, staleness threshold and revision retention',
|
||||
'position': 21,
|
||||
},
|
||||
]
|
||||
|
||||
def get_settings_defaults(self) -> List[Dict]:
|
||||
"""Schedule + retention, read by the collector and the prune job.
|
||||
|
||||
@@ -263,6 +288,15 @@ class BackupsPlugin(BasePlugin):
|
||||
'type': 'string',
|
||||
'description': "Full UNC path. Required for 'share' kinds.",
|
||||
},
|
||||
'manifest': {
|
||||
'type': 'object',
|
||||
'description': "Optional description of a 'share' kind's "
|
||||
"contents - what is in the snapshot, not "
|
||||
"the bytes themselves. Lets an asset panel "
|
||||
"list a snapshot and diff it against the "
|
||||
"previous one without the app server "
|
||||
"mounting the share.",
|
||||
},
|
||||
'sourcefilename': {'type': 'string'},
|
||||
'sourcehostname': {
|
||||
'type': 'string',
|
||||
@@ -325,6 +359,42 @@ class BackupsPlugin(BasePlugin):
|
||||
"contenthash and sharepath are required for kind '{}'".format(
|
||||
kindkey))
|
||||
|
||||
# A share kind may also describe itself. The bytes stay on the
|
||||
# share - that part is unchanged - but a manifest of what the
|
||||
# snapshot CONTAINS is small, structured, and the only way an asset
|
||||
# panel can list a part marker's patterns or say which one
|
||||
# disappeared last month. The alternative is the app server
|
||||
# mounting SFLD, which the read routes deliberately refuse to do.
|
||||
#
|
||||
# It is advisory: dedup still keys on contenthash, so a client that
|
||||
# sends no manifest behaves exactly as before.
|
||||
manifest = payload.get('manifest')
|
||||
if manifest is not None:
|
||||
# Validated at the door, not at render time. A shallow check
|
||||
# here would let a malformed manifest become the LATEST
|
||||
# revision, and every view of the asset panel would then fail
|
||||
# until the content next changed - a client bug turning into a
|
||||
# broken page that outlives it.
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError('manifest must be an object')
|
||||
files = manifest.get('files')
|
||||
if files is not None:
|
||||
if not isinstance(files, list):
|
||||
raise ValueError('manifest files must be a list')
|
||||
for entry in files:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError(
|
||||
'each manifest file must be an object')
|
||||
if not isinstance(entry.get('name'), str) or not entry['name']:
|
||||
raise ValueError(
|
||||
'each manifest file needs a name')
|
||||
size = entry.get('bytes')
|
||||
if size is not None and not isinstance(size, int):
|
||||
raise ValueError(
|
||||
'manifest file {!r} has a non-integer size'.format(
|
||||
entry['name']))
|
||||
projection = manifest
|
||||
|
||||
# Dedup is against the LATEST row of THIS SOURCE's chain, not the
|
||||
# asset's. Several PCs legitimately share one machine number here - the
|
||||
# part markers on 0613, 0615 and WJPRT do, and their configs genuinely
|
||||
@@ -356,6 +426,26 @@ class BackupsPlugin(BasePlugin):
|
||||
# backup that died six months ago. Cheap: one column on a row that
|
||||
# already exists, no new history.
|
||||
latest.lastseenat = datetime.utcnow()
|
||||
|
||||
# BACKFILL, on a no-op. The contenthash attests the BYTES, not the
|
||||
# manifest, so a client that starts describing itself posts the same
|
||||
# hash as before and would otherwise no-op forever - leaving the
|
||||
# index unstored on exactly the markers that are healthy and
|
||||
# unchanging, which is most of them. A revision that already has a
|
||||
# manifest is left alone; this only fills a hole.
|
||||
#
|
||||
# Honest by construction: the hash proves the content is identical,
|
||||
# so indexing it now describes the same snapshot. lastseenat
|
||||
# semantics are untouched.
|
||||
if projection is not None and latest.payload is None:
|
||||
latest.payload = projection
|
||||
# Same reasoning for the pointer: a snapshot that moved on the share
|
||||
# (a changed layout, a renamed asset directory) reports a new
|
||||
# sharepath with unchanged bytes, and the stored path would
|
||||
# otherwise go on pointing at a directory nobody writes to.
|
||||
if sharepath and latest.sharepath != sharepath:
|
||||
latest.sharepath = sharepath
|
||||
|
||||
db.session.flush()
|
||||
return {
|
||||
'action': 'noop',
|
||||
|
||||
184
plugins/backups/services/partmarkerinfo.py
Normal file
184
plugins/backups/services/partmarkerinfo.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Marker Contents card.
|
||||
|
||||
What a part marker holds right now, on the marker's own asset page, plus what
|
||||
changed since the previous snapshot.
|
||||
|
||||
WHY THIS EXISTS AT ALL
|
||||
|
||||
A 'share' kind keeps its bytes on SFLD and only a UNC pointer in ShopDB, so
|
||||
the obvious way to list a snapshot's contents - read the directory - is
|
||||
exactly what the read routes refuse to do: mounting the share in the app
|
||||
server turns a permissions slip into an unexplained empty panel. Instead the
|
||||
collector sends a MANIFEST alongside the hash, and this builds the card from
|
||||
that. Small, structured, and it works from a server that cannot see the
|
||||
share at all.
|
||||
|
||||
WHAT IT ANSWERS
|
||||
|
||||
"What patterns are on this marker" - the question a tech arrives with, and
|
||||
today the only way to answer it is to walk to the bay and read the panel.
|
||||
|
||||
"What changed" - the added/removed diff against the previous snapshot. This
|
||||
is the reason the snapshot layout exists: a pattern deleted from a
|
||||
controller is invisible everywhere else until someone needs it. Here it is
|
||||
dated, and the snapshot that still holds it is one row above in the history.
|
||||
|
||||
The manifest is written by Backup-PartMarker.ps1 and carries, per file: name,
|
||||
bytes, sha256, and the marking system it was authored for.
|
||||
"""
|
||||
|
||||
|
||||
def _previousmanifest(assetid, currentid):
|
||||
"""The manifest of the snapshot before this one, or None.
|
||||
|
||||
Scoped to the same source host, matching how dedup chains revisions: two
|
||||
PCs reporting the same marker keep separate histories, and diffing across
|
||||
them would report every file as added and removed at once.
|
||||
|
||||
The host comes from the REVISION's own column, not from a field inside the
|
||||
manifest. Those can disagree - the column is what the collector recorded and
|
||||
what retention chains on - and using the manifest's copy would silently diff
|
||||
against the wrong chain, or against nothing at all.
|
||||
"""
|
||||
from shopdb.api import db
|
||||
from ..models import BackupRevision
|
||||
from .retention import samesource
|
||||
|
||||
current = db.session.get(BackupRevision, currentid)
|
||||
if current is None:
|
||||
return None, None
|
||||
sourcehostname = current.sourcehostname
|
||||
|
||||
row = (db.session.query(BackupRevision)
|
||||
.filter(BackupRevision.assetid == assetid,
|
||||
BackupRevision.backupkind == 'partmarker',
|
||||
BackupRevision.backuprevisionid < currentid,
|
||||
samesource(sourcehostname))
|
||||
.order_by(BackupRevision.backuprevisionid.desc())
|
||||
.first())
|
||||
if row is None:
|
||||
return None, None
|
||||
when = row.collectedat or row.createdat
|
||||
try:
|
||||
return row.payload, when
|
||||
except ValueError:
|
||||
# A corrupt neighbour must not take the card down - the current
|
||||
# snapshot is still worth showing without its diff.
|
||||
return None, None
|
||||
|
||||
|
||||
def _hashes(manifest):
|
||||
"""name -> sha256, for spotting a file edited in place."""
|
||||
return {entry.get('name'): entry.get('sha256')
|
||||
for entry in (manifest or {}).get('files', [])
|
||||
if entry.get('name') and entry.get('sha256')}
|
||||
|
||||
|
||||
def _names(manifest):
|
||||
return {entry.get('name') for entry in (manifest or {}).get('files', [])
|
||||
if entry.get('name')}
|
||||
|
||||
|
||||
def _summarise(files):
|
||||
"""Counts by extension, so 34 patterns and 12 settings read as two facts."""
|
||||
groups = {}
|
||||
for entry in files:
|
||||
name = entry.get('name') or ''
|
||||
ext = name.rsplit('.', 1)[-1].upper() if '.' in name else 'OTHER'
|
||||
groups[ext] = groups.get(ext, 0) + 1
|
||||
labels = {'PAT': 'Patterns', 'DAT': 'Settings files', 'TFF': 'Fonts'}
|
||||
return [(labels.get(ext, ext), count) for ext, count in sorted(groups.items())]
|
||||
|
||||
|
||||
def build(projection, assetid, revisionid=None, sourcehostname=None,
|
||||
sharepath=None, **options):
|
||||
"""Build the card from a stored partmarker manifest.
|
||||
|
||||
Returns the keyvalue shape. An older revision predating manifests has no
|
||||
projection at all, which renders as no card rather than an error - the
|
||||
revision itself is still listed in the history panel.
|
||||
"""
|
||||
# "No manifest" and "a manifest listing nothing" are different facts. The
|
||||
# first is an older revision predating manifests, which has no card. The
|
||||
# second is a controller that has been wiped or replaced - precisely the
|
||||
# disaster the Removed line exists to report - so it must render, with the
|
||||
# diff intact.
|
||||
if not projection:
|
||||
return {'fields': [], 'sectioncount': 0}
|
||||
manifest = projection
|
||||
files = manifest.get('files') or []
|
||||
|
||||
fields = []
|
||||
|
||||
for label, count in _summarise(files):
|
||||
fields.append({'label': label, 'value': str(count)})
|
||||
|
||||
total = sum(int(entry.get('bytes') or 0) for entry in files)
|
||||
fields.append({'label': 'Total size', 'value': '{:,} bytes'.format(total)})
|
||||
|
||||
if manifest.get('family'):
|
||||
fields.append({'label': 'Controller', 'value': manifest['family']})
|
||||
|
||||
# The heads patterns were authored for. Worth showing because they can
|
||||
# DIFFER from the controller holding them - a TMC420 in the reference site
|
||||
# holds both a TMP3200/470 and a TMP4210/420 pattern - and pushing a pattern
|
||||
# back onto a system with a different head is a mistake worth catching
|
||||
# before the restore, not after.
|
||||
systems = manifest.get('systems') or []
|
||||
if systems:
|
||||
fields.append({'label': 'Authored for', 'value': ', '.join(systems)})
|
||||
|
||||
# The snapshot's own location, in full, as plain text. NOT a link: browsers
|
||||
# refuse to navigate from an https page to file://, so a clickable path
|
||||
# would look actionable and silently do nothing, which is worse than no
|
||||
# link at all. The history page pairs the same string with a copy button;
|
||||
# here it is what a tech reads or selects before pasting it into Explorer.
|
||||
if sharepath:
|
||||
fields.append({'label': 'Snapshot', 'value': sharepath, 'mono': True})
|
||||
|
||||
# From the revision's own column where the caller passes it: the manifest
|
||||
# carries its own copy, and the two can disagree. The column is what the
|
||||
# collector recorded and what the diff above is chained on, so naming a
|
||||
# different PC here would contradict the comparison shown beside it.
|
||||
capturedfrom = sourcehostname or manifest.get('sourcehost')
|
||||
if capturedfrom:
|
||||
fields.append({'label': 'Captured from', 'value': capturedfrom})
|
||||
|
||||
# The diff. Only meaningful with a predecessor: on a marker's first snapshot
|
||||
# every file would otherwise read as "added", which says nothing.
|
||||
if revisionid:
|
||||
previous, when = _previousmanifest(assetid, revisionid)
|
||||
if previous is not None:
|
||||
before, now = _names(previous), _names(manifest)
|
||||
added = sorted(now - before)
|
||||
removed = sorted(before - now)
|
||||
|
||||
# Same name, different bytes. A revision exists only because the
|
||||
# content hash moved, so if the file SET is identical then something
|
||||
# was edited in place - and saying "no files added or removed" would
|
||||
# be a flat contradiction of why this row exists. The per-file
|
||||
# sha256 is in the manifest, so the edited files can be named.
|
||||
beforehashes = _hashes(previous)
|
||||
changed = sorted(name for name, digest in _hashes(manifest).items()
|
||||
if name in beforehashes and beforehashes[name] != digest)
|
||||
|
||||
# Date the window. Retention prunes middle revisions, so "previous"
|
||||
# can be months older than the row before it in the history, and an
|
||||
# undated diff would quietly attribute an old removal to this
|
||||
# capture.
|
||||
label = 'Since {}'.format(when.strftime('%Y-%m-%d')) if when else 'Since previous'
|
||||
|
||||
if added:
|
||||
fields.append({'label': 'Added', 'value': ', '.join(added)})
|
||||
if removed:
|
||||
# The one the whole design exists for. Named, not counted -
|
||||
# "1 removed" would send someone to the share to find out which.
|
||||
fields.append({'label': 'Removed', 'value': ', '.join(removed)})
|
||||
if changed:
|
||||
fields.append({'label': 'Changed', 'value': ', '.join(changed)})
|
||||
if not (added or removed or changed):
|
||||
fields.append({'label': label, 'value': 'No files added, removed or changed'})
|
||||
else:
|
||||
fields.append({'label': 'Compared with', 'value': label.replace('Since ', '')})
|
||||
|
||||
return {'fields': fields, 'sectioncount': 1}
|
||||
@@ -279,11 +279,61 @@ class PartMarkerKind(BackupKind):
|
||||
key = 'partmarker'
|
||||
displayname = 'Part Marker Configuration'
|
||||
storagebackend = 'share'
|
||||
# NO asset panel. The marker's DNC settings are a tab on the single DNC Info
|
||||
# card, and a separate card that is empty on 145 of 147 machines earns
|
||||
# nobody anything. The kind is still fully live - it stores, dedupes and
|
||||
# serves revisions, which are listed on the backup history page.
|
||||
assettypes = []
|
||||
# Marker assets are core type 'machine', like the other 144 machines - but
|
||||
# only a marker ever has a partmarker revision, and the list renderer hides
|
||||
# a panel with no rows and no empty text. So declaring 'machine' shows the
|
||||
# panel on markers and nowhere else, with no special-casing.
|
||||
#
|
||||
# This was deliberately EMPTY while backups were filed against the operation
|
||||
# number, when a partmarker card would have appeared on every machine and
|
||||
# said nothing. Since a marker PC's backup is filed against its own marker
|
||||
# asset (see markerforsource), the panel has a home of its own.
|
||||
assettypes = ['machine']
|
||||
|
||||
def sharedir(self, machinetype, identifier, shareroot=None):
|
||||
"""<root>\\partmarker\\<marker asset>\\ - the SITE's layout, which
|
||||
inverts the base class's <root>\\<machinetype>\\<identifier>\\<kind>.
|
||||
|
||||
Overridden rather than left to the default so what ShopDB displays and
|
||||
validates matches what the collector actually writes. Still advisory:
|
||||
the authoritative path is the sharepath on each revision, which is what
|
||||
keeps older snapshots resolvable after a PC swap renames the asset.
|
||||
|
||||
Snapshots live in timestamped subdirectories BELOW this - one per
|
||||
capture, so a pattern deleted from the controller stays recoverable
|
||||
from the snapshot that still holds it.
|
||||
"""
|
||||
root = shareroot or DEFAULTSHAREROOT
|
||||
if not root:
|
||||
return ''
|
||||
return '{}\\{}\\{}'.format(root, self.key, identifier or 'unknown')
|
||||
|
||||
def infopanel(self):
|
||||
"""Contents of the latest snapshot, on the marker's own asset page.
|
||||
|
||||
A 'share' kind stores no bytes in ShopDB, so this is built from the
|
||||
manifest the collector sends alongside the hash. Without it the app
|
||||
server would have to mount SFLD to say what a snapshot contains, which
|
||||
the read routes refuse to do on purpose.
|
||||
"""
|
||||
return {
|
||||
'id': 'backups-partmarker-contents',
|
||||
'title': 'Marker Contents',
|
||||
'assettypes': ['machine'],
|
||||
'endpoint': '/api/backups/asset/{assetid}/info?kind=partmarker',
|
||||
'render': 'keyvalue',
|
||||
# Above the history panel: "what is on this marker" is the question
|
||||
# a tech arrives with; the history is for the rarer restore.
|
||||
'position': 39,
|
||||
}
|
||||
|
||||
def buildinfo(self, projection, assetid, **options):
|
||||
from . import partmarkerinfo
|
||||
return partmarkerinfo.build(
|
||||
projection, assetid,
|
||||
revisionid=options.get('revisionid'),
|
||||
sourcehostname=options.get('sourcehostname'),
|
||||
sharepath=options.get('sharepath'))
|
||||
|
||||
def resolveassetid(self, payload):
|
||||
from shopdb.api import db, Asset
|
||||
|
||||
@@ -1079,10 +1079,64 @@ def _attach_backup_state(facts, hostnames):
|
||||
rows.sort(key=lambda revision: (revision.lastseenat is not None,
|
||||
revision.lastseenat or _EPOCH),
|
||||
reverse=True)
|
||||
|
||||
# Reduce to the FRESHEST revision per (host, kind), then judge a host by its
|
||||
# STALEST kind.
|
||||
#
|
||||
# Until part markers, a PC reported exactly one kind and "newest wins" was
|
||||
# the same thing as "the truth". A marker bay reports two - ntlars for the
|
||||
# machine's DNC settings and partmarker for the controller's patterns - and
|
||||
# newest-wins then means the daily ntlars post keeps the badge green while
|
||||
# the marker backup has been failing for a month. The failure would show
|
||||
# only on that marker's own asset page, which is not where anyone looks to
|
||||
# find out that something stopped.
|
||||
#
|
||||
# So: a host is healthy when EVERYTHING it backs up is current, and the
|
||||
# badge names the kind that is not. Freshest-per-kind first, because
|
||||
# lastseenat only moves on the newest revision of a chain - an older
|
||||
# revision of the same kind keeps its original value and would otherwise
|
||||
# read as stale for ever.
|
||||
#
|
||||
# KNOWN CONSEQUENCE: a device retired from a PC leaves its last revision
|
||||
# behind, so that host stays stale until the revision is pruned or removed.
|
||||
# That is the honest failure direction - it complains about a backup that
|
||||
# genuinely is not running any more, rather than staying quiet about one.
|
||||
freshest = {}
|
||||
for revision in rows:
|
||||
key = (revision.sourcehostname or '').lower()
|
||||
if key not in facts:
|
||||
continue
|
||||
kindkey = (key, revision.backupkind)
|
||||
if kindkey not in freshest:
|
||||
# rows are already newest-confirmed-first, so the first is freshest.
|
||||
freshest[kindkey] = revision
|
||||
|
||||
# Among kinds that can be JUDGED, the stalest decides. A kind whose newest
|
||||
# revision has never been confirmed (lastseenat NULL - a backfilled row, or
|
||||
# one written before the column existed) is not stale; it is unmeasurable,
|
||||
# and it must not win.
|
||||
#
|
||||
# Treating NULL as "worst" looked right and was wrong: a host with a healthy
|
||||
# ntlars backup and one never-confirmed row showed NO BADGE, because the
|
||||
# verdict for a NULL confirmation is tri-state None. A working backup then
|
||||
# renders as nothing at all, which is the one thing the badge must never do.
|
||||
# This is the same rule staleness.py already applies: a chain becomes
|
||||
# measurable the first time its PC posts, and says nothing until then.
|
||||
stalest = {}
|
||||
for (key, _kind), revision in freshest.items():
|
||||
if revision.lastseenat is None:
|
||||
continue
|
||||
current = stalest.get(key)
|
||||
if current is None or revision.lastseenat < current.lastseenat:
|
||||
stalest[key] = revision
|
||||
|
||||
# Every kind unmeasurable: fall back to any revision so the host still
|
||||
# reports which kind it has, with a None verdict and no badge.
|
||||
for (key, _kind), revision in freshest.items():
|
||||
stalest.setdefault(key, revision)
|
||||
|
||||
for key, revision in stalest.items():
|
||||
entry = facts.get(key)
|
||||
# First row per host wins.
|
||||
if entry and entry['backuplastseen'] is None:
|
||||
entry['backupkind'] = revision.backupkind
|
||||
entry['backuplastseen'] = (
|
||||
|
||||
13
plugins/guides/manifest.json
Normal file
13
plugins/guides/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "guides",
|
||||
"display_name": "Guides",
|
||||
"version": "1.0.0",
|
||||
"description": "Step-by-step guides written and read inside ShopDB, with images, working checklists and printable output",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/guides",
|
||||
"provides": {
|
||||
"features": ["guides"]
|
||||
}
|
||||
}
|
||||
3
plugins/guides/models/__init__.py
Normal file
3
plugins/guides/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .guide import Guide, GuideImage, BLOCKTYPES
|
||||
|
||||
__all__ = ['Guide', 'GuideImage', 'BLOCKTYPES']
|
||||
188
plugins/guides/models/guide.py
Normal file
188
plugins/guides/models/guide.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Guides: step-by-step procedures written and read inside ShopDB.
|
||||
|
||||
WHY THIS IS NOT THE KNOWLEDGE BASE PLUGIN
|
||||
|
||||
The knowledgebase plugin is a LINK REGISTRY - a short description, a URL and
|
||||
some keywords, pointing at a document that lives somewhere else. That is a
|
||||
different job. This holds the document itself.
|
||||
|
||||
WHY BLOCKS RATHER THAN A BLOB OF HTML
|
||||
|
||||
A guide is stored as an ordered list of typed blocks, each with a stable id,
|
||||
serialized as JSON. Free-form HTML would be easier to write and would defeat
|
||||
three of the four things this feature exists for:
|
||||
|
||||
checklists a tickable step has to be identifiable to be remembered, and
|
||||
an <input> buried in pasted markup has no identity
|
||||
dark mode pasted content carries hardcoded colours, which then fight the
|
||||
theme - black text on a dark background, and nobody can say
|
||||
why
|
||||
printing print has to know where a step begins and ends to avoid
|
||||
splitting one across a page
|
||||
|
||||
Blocks also mean a new block type later is additive: old guides keep
|
||||
rendering, because the renderer skips what it does not know.
|
||||
|
||||
INLINE TEXT IS A RESTRICTED MARKDOWN SUBSET - bold, italic, inline code, links,
|
||||
line breaks, and nothing else. No fonts, sizes or colours: emphasis is semantic
|
||||
and the theme decides how it looks. That single rule is what keeps a guide
|
||||
written next year looking like one written today, and what makes dark mode work
|
||||
without an author thinking about it.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
from shopdb.core.models.base import BaseModel
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
# Block types the renderer knows. Stored as data, not as markup, so the same
|
||||
# guide can render to screen, to dark mode and to paper differently.
|
||||
BLOCKTYPES = (
|
||||
'heading',
|
||||
'paragraph',
|
||||
'step', # a numbered step, with its own body
|
||||
'checklist', # tickable items - the reason progress is a feature
|
||||
'bullets',
|
||||
'numbers',
|
||||
'callout', # info / warning / success, per the KB house style
|
||||
'code',
|
||||
'image',
|
||||
'table',
|
||||
)
|
||||
|
||||
|
||||
class Guide(BaseModel):
|
||||
"""One guide. Blocks live in a JSON column rather than a child table.
|
||||
|
||||
A guide is edited and saved WHOLE - nobody reorders one paragraph in
|
||||
isolation - so a child table would buy ordering machinery, a second
|
||||
migration and N+1 reads, in exchange for nothing this feature needs. The
|
||||
same reasoning the geenforce manifests and backup payloads already follow.
|
||||
"""
|
||||
|
||||
__tablename__ = 'guides'
|
||||
|
||||
guideid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Human title, and the slug the URL uses. The slug is stored rather than
|
||||
# derived on read so an edited title cannot silently break a link somebody
|
||||
# pasted into a work order.
|
||||
title = db.Column(db.String(300), nullable=False)
|
||||
slug = db.Column(db.String(300), nullable=False, unique=True, index=True)
|
||||
|
||||
# One line, shown in the list and in search results.
|
||||
summary = db.Column(db.String(1000))
|
||||
|
||||
# Comma-separated, matching how knowledgebase.keywords already works, so one
|
||||
# search can cover both and there is one tag vocabulary rather than two.
|
||||
tags = db.Column(db.String(500))
|
||||
|
||||
# 'draft' or 'published'. A draft is visible to people who can edit, and
|
||||
# invisible to everyone else: a half-written procedure on a shop floor is
|
||||
# worse than no procedure.
|
||||
status = db.Column(db.String(20), nullable=False, default='draft', index=True)
|
||||
|
||||
# MEDIUMTEXT: serialized blocks. Text rather than a native JSON column so the
|
||||
# bytes come back exactly as written and a diff between versions is stable -
|
||||
# the same reason the backups plugin stores its projection as text.
|
||||
blocksjson = db.Column(db.Text(length=16777215), nullable=False, default='[]')
|
||||
|
||||
createdby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
updatedby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
||||
updatedat = db.Column(db.DateTime, nullable=False, default=_utcnow,
|
||||
onupdate=_utcnow)
|
||||
publishedat = db.Column(db.DateTime)
|
||||
|
||||
# Bumped on every save. Cheap, and it gives a printed copy something
|
||||
# honest to put in its footer: "version 7, printed 26 Aug 2026".
|
||||
revision = db.Column(db.Integer, nullable=False, default=1)
|
||||
|
||||
@property
|
||||
def blocks(self):
|
||||
"""The decoded block list, or [] on a corrupt row.
|
||||
|
||||
Deliberately forgiving, unlike the backups payload getter: a guide that
|
||||
will not render is a page a person cannot read, and an empty guide with
|
||||
its title and tags intact is a better failure than an error page. The
|
||||
corruption is still visible - the editor shows no blocks.
|
||||
"""
|
||||
if not self.blocksjson:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(self.blocksjson)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
@blocks.setter
|
||||
def blocks(self, value):
|
||||
self.blocksjson = json.dumps(value or [], separators=(',', ':'))
|
||||
|
||||
def taglist(self):
|
||||
"""Tags as a clean list, empties dropped."""
|
||||
return [t.strip() for t in (self.tags or '').split(',') if t.strip()]
|
||||
|
||||
def to_dict(self, includeblocks=False):
|
||||
data = {
|
||||
'guideid': self.guideid,
|
||||
'title': self.title,
|
||||
'slug': self.slug,
|
||||
'summary': self.summary,
|
||||
'tags': self.taglist(),
|
||||
'status': self.status,
|
||||
'revision': self.revision,
|
||||
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
|
||||
'updatedat': self.updatedat.isoformat() + 'Z' if self.updatedat else None,
|
||||
'publishedat': (self.publishedat.isoformat() + 'Z'
|
||||
if self.publishedat else None),
|
||||
}
|
||||
if includeblocks:
|
||||
data['blocks'] = self.blocks
|
||||
return data
|
||||
|
||||
|
||||
class GuideImage(BaseModel):
|
||||
"""An image pasted or dropped into a guide.
|
||||
|
||||
Bytes live in the database, not on disk. Guides are small, the images are
|
||||
screenshots rather than photographs, and a row travels with a database
|
||||
backup - whereas a file under instance/ has been lost to a container rebuild
|
||||
on this very deployment before. If a site ever puts thousands of these in,
|
||||
the storage decision is one column and one route to revisit.
|
||||
"""
|
||||
|
||||
__tablename__ = 'guideimages'
|
||||
|
||||
guideimageid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Nullable: an image is uploaded while a NEW guide is still unsaved, so it
|
||||
# exists before the guide it belongs to does. The editor attaches it on
|
||||
# save; anything still unattached is orphaned and prunable.
|
||||
guideid = db.Column(db.Integer, db.ForeignKey('guides.guideid',
|
||||
ondelete='CASCADE'), index=True)
|
||||
|
||||
filename = db.Column(db.String(255))
|
||||
mimetype = db.Column(db.String(100), nullable=False)
|
||||
bytesize = db.Column(db.Integer, nullable=False)
|
||||
data = db.Column(db.LargeBinary(length=(2 ** 24) - 1), nullable=False)
|
||||
|
||||
uploadedby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'guideimageid': self.guideimageid,
|
||||
'guideid': self.guideid,
|
||||
'filename': self.filename,
|
||||
'mimetype': self.mimetype,
|
||||
'bytesize': self.bytesize,
|
||||
'url': '/api/guides/images/{}'.format(self.guideimageid),
|
||||
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
|
||||
}
|
||||
0
plugins/guides/services/__init__.py
Normal file
0
plugins/guides/services/__init__.py
Normal file
131
plugins/guides/services/blocks.py
Normal file
131
plugins/guides/services/blocks.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Block validation.
|
||||
|
||||
The editor is the only thing that writes blocks today, so this is not defending
|
||||
against a hostile author - it is defending against a BAD SAVE. A block list that
|
||||
is wrong in a way nobody notices at save time becomes a guide that will not
|
||||
render weeks later, when the person who wrote it has moved on and the only
|
||||
evidence is a blank page.
|
||||
|
||||
So: validate at the door, name the block that is wrong, and refuse the save.
|
||||
The alternative - storing whatever arrives and coping at render time - was tried
|
||||
in the backups plugin with a manifest and produced exactly the failure this
|
||||
avoids: a client bug turning into a broken page that outlived it.
|
||||
|
||||
WHAT IS NOT VALIDATED HERE: the inline text. It is a restricted Markdown subset
|
||||
rendered through a sanitiser in the browser, which is where a renderer belongs.
|
||||
Storing text verbatim keeps what the author typed recoverable even if the
|
||||
rendering rules change later.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from ..models import BLOCKTYPES
|
||||
|
||||
# A guide is a procedure, not a document repository. This cap is generous for
|
||||
# anything a person will read on a shop floor and small enough that a runaway
|
||||
# paste cannot fill a MEDIUMTEXT column.
|
||||
MAXBLOCKSBYTES = 2 * 1024 * 1024
|
||||
MAXBLOCKS = 500
|
||||
MAXTEXT = 20000
|
||||
|
||||
|
||||
class BlockError(ValueError):
|
||||
"""Raised with a message naming the offending block, for the API to relay."""
|
||||
|
||||
|
||||
def _fail(index, message):
|
||||
raise BlockError('block {}: {}'.format(index + 1, message))
|
||||
|
||||
|
||||
def validate(blocks):
|
||||
"""Check a block list, returning it unchanged, or raise BlockError.
|
||||
|
||||
Kept pure - no database, no request - so the rules are testable directly and
|
||||
the same function can be reused by an importer later.
|
||||
"""
|
||||
if not isinstance(blocks, list):
|
||||
raise BlockError('blocks must be a list')
|
||||
if len(blocks) > MAXBLOCKS:
|
||||
raise BlockError('a guide is limited to {} blocks'.format(MAXBLOCKS))
|
||||
|
||||
seen = set()
|
||||
for index, block in enumerate(blocks):
|
||||
if not isinstance(block, dict):
|
||||
_fail(index, 'must be an object')
|
||||
|
||||
blockid = block.get('id')
|
||||
if not isinstance(blockid, str) or not blockid.strip():
|
||||
# Ids are what make a checklist item rememberable between visits.
|
||||
# A block without one silently loses its tick the next time the
|
||||
# guide is opened, which reads as "the checkboxes do not work".
|
||||
_fail(index, 'needs a stable id')
|
||||
if blockid in seen:
|
||||
_fail(index, 'duplicate id {!r} - ticks would follow the wrong item'
|
||||
.format(blockid))
|
||||
seen.add(blockid)
|
||||
|
||||
blocktype = block.get('type')
|
||||
if blocktype not in BLOCKTYPES:
|
||||
_fail(index, 'unknown type {!r}. Known types: {}'.format(
|
||||
blocktype, ', '.join(BLOCKTYPES)))
|
||||
|
||||
text = block.get('text')
|
||||
if text is not None:
|
||||
if not isinstance(text, str):
|
||||
_fail(index, 'text must be a string')
|
||||
if len(text) > MAXTEXT:
|
||||
_fail(index, 'text is longer than {} characters'.format(MAXTEXT))
|
||||
|
||||
items = block.get('items')
|
||||
if items is not None:
|
||||
if not isinstance(items, list):
|
||||
_fail(index, 'items must be a list')
|
||||
for item in items:
|
||||
# Checklist items carry their own id for the same reason blocks
|
||||
# do: that id is the thing a tick is stored against.
|
||||
if isinstance(item, dict):
|
||||
if not isinstance(item.get('id'), str) or not item['id'].strip():
|
||||
_fail(index, 'every item needs an id')
|
||||
if not isinstance(item.get('text', ''), str):
|
||||
_fail(index, 'item text must be a string')
|
||||
elif not isinstance(item, str):
|
||||
_fail(index, 'items must be strings or objects')
|
||||
|
||||
encoded = json.dumps(blocks, separators=(',', ':'))
|
||||
if len(encoded.encode('utf-8')) > MAXBLOCKSBYTES:
|
||||
raise BlockError('this guide is larger than {} MB'.format(
|
||||
MAXBLOCKSBYTES // (1024 * 1024)))
|
||||
return blocks
|
||||
|
||||
|
||||
def plaintext(blocks):
|
||||
"""Flatten a guide to searchable text.
|
||||
|
||||
Search should find a guide by something written halfway down it, not only by
|
||||
its title - that is how people actually look for a procedure ("the one about
|
||||
the drive letter"). Cheap because it runs on save, not on every query.
|
||||
"""
|
||||
parts = []
|
||||
for block in blocks or []:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if isinstance(block.get('text'), str):
|
||||
parts.append(block['text'])
|
||||
for item in block.get('items') or []:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, dict) and isinstance(item.get('text'), str):
|
||||
parts.append(item['text'])
|
||||
for row in block.get('rows') or []:
|
||||
if isinstance(row, list):
|
||||
parts.extend(cell for cell in row if isinstance(cell, str))
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def checklistitemcount(blocks):
|
||||
"""How many tickable items a guide has, for the list and the progress bar."""
|
||||
total = 0
|
||||
for block in blocks or []:
|
||||
if isinstance(block, dict) and block.get('type') == 'checklist':
|
||||
total += len(block.get('items') or [])
|
||||
return total
|
||||
Reference in New Issue
Block a user