diff --git a/plugins/guides/api/routes.py b/plugins/guides/api/routes.py index 293af01b..ac59426c 100644 --- a/plugins/guides/api/routes.py +++ b/plugins/guides/api/routes.py @@ -84,7 +84,7 @@ def _slugify(title, guideid=None): somebody pasted into a work order months ago. """ base = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') or 'guide' - base = base[:200] + base = base[:180] # room for a '-2' style suffix inside the 191 cap candidate = base suffix = 2 while True: @@ -135,7 +135,7 @@ def list_guides(): Guide.createdby == mine, )) - rows = query.order_by(Guide.updatedat.desc()).all() + rows = query.order_by(Guide.modifieddate.desc()).all() out = [] for guide in rows: diff --git a/plugins/guides/frontend/components/GuideBlocks.vue b/plugins/guides/frontend/components/GuideBlocks.vue index 941a1457..3ddd31b9 100644 --- a/plugins/guides/frontend/components/GuideBlocks.vue +++ b/plugins/guides/frontend/components/GuideBlocks.vue @@ -230,11 +230,21 @@ async function copy (block) { .gb-table th { background: var(--surface-2, rgba(127,127,127,0.08)); } @media print { - /* Paper has no hover, no clipboard and no theme. */ + /* Paper has no hover, no clipboard and no theme. The light-mode token + overrides live in GuideView.vue, which can reach :root; this block is only + about what belongs on paper. */ .gb-copy { display: none; } - .gb-step, .gb-callout, .gb-code, .gb-figure { break-inside: avoid; } - .gb-check input { /* keep the box, printed empty, so it can be ticked by hand */ } + .gb-step, .gb-callout, .gb-code, .gb-figure, .gb-check { break-inside: avoid; } + + /* Checkboxes print EMPTY on purpose, even where they are ticked on screen: + a printed guide is a worksheet somebody carries to the bay and ticks by + hand, and one person's progress is not part of the procedure. */ .gb-done { opacity: 1; text-decoration: none; } + .gb-check input { appearance: none; -webkit-appearance: none; + width: 0.85rem; height: 0.85rem; border: 1px solid #000; + border-radius: 2px; background: #fff; } + .gb-code pre { white-space: pre-wrap; } + .gb-stepnum { color: #000; } } diff --git a/plugins/guides/frontend/views/GuideView.vue b/plugins/guides/frontend/views/GuideView.vue index fb2e61c0..e0ff3686 100644 --- a/plugins/guides/frontend/views/GuideView.vue +++ b/plugins/guides/frontend/views/GuideView.vue @@ -218,3 +218,54 @@ watch(() => route.params.slug, load) .gv-foot { border-top: 1px solid #999; } } + + diff --git a/plugins/guides/migrations/versions/0001_guides_baseline.py b/plugins/guides/migrations/versions/0001_guides_baseline.py index 2ebd51f1..8964c3a7 100644 --- a/plugins/guides/migrations/versions/0001_guides_baseline.py +++ b/plugins/guides/migrations/versions/0001_guides_baseline.py @@ -30,7 +30,9 @@ def upgrade(): 'guides', sa.Column('guideid', sa.Integer(), nullable=False), sa.Column('title', sa.String(length=300), nullable=False), - sa.Column('slug', sa.String(length=300), nullable=False), + # 191 because this is UNIQUE-indexed and MySQL caps an index key at + # 767 bytes; utf8mb4 is 4 bytes per character. + sa.Column('slug', sa.String(length=191), nullable=False), sa.Column('summary', sa.String(length=1000)), sa.Column('tags', sa.String(length=500)), sa.Column('status', sa.String(length=20), nullable=False, @@ -41,9 +43,13 @@ def upgrade(): sa.Column('publishedrevisionid', sa.Integer()), sa.Column('createdby', sa.Integer()), sa.Column('updatedby', sa.Integer()), - sa.Column('createdat', sa.DateTime(), nullable=False), - sa.Column('updatedat', sa.DateTime(), nullable=False), sa.Column('publishedat', sa.DateTime()), + # BaseModel contributes these to every model in the app; a plugin + # migration has to spell them out or the table is missing columns + # the ORM selects on every query. + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), sa.Column('revision', sa.Integer(), nullable=False, server_default='1'), sa.PrimaryKeyConstraint('guideid'), sa.UniqueConstraint('slug', name='uq_guides_slug'), @@ -65,7 +71,12 @@ def upgrade(): sa.Column('contenthash', sa.String(length=64), nullable=False), sa.Column('note', sa.String(length=500)), sa.Column('createdby', sa.Integer()), - sa.Column('createdat', sa.DateTime(), nullable=False), + # BaseModel contributes these to every model in the app; a plugin + # migration has to spell them out or the table is missing columns + # the ORM selects on every query. + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), sa.PrimaryKeyConstraint('guiderevisionid'), sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'], ondelete='CASCADE'), @@ -85,7 +96,12 @@ def upgrade(): if op.get_bind().dialect.name == 'mysql' else sa.LargeBinary(), nullable=False), sa.Column('uploadedby', sa.Integer()), - sa.Column('createdat', sa.DateTime(), nullable=False), + # BaseModel contributes these to every model in the app; a plugin + # migration has to spell them out or the table is missing columns + # the ORM selects on every query. + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), sa.PrimaryKeyConstraint('guideimageid'), sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'], ondelete='CASCADE'), diff --git a/plugins/guides/models/guide.py b/plugins/guides/models/guide.py index 6cbda923..a3587798 100644 --- a/plugins/guides/models/guide.py +++ b/plugins/guides/models/guide.py @@ -74,7 +74,12 @@ class Guide(BaseModel): # 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) + # 191, not 300: this column is UNIQUE-indexed, and MySQL caps an index key + # at 767 bytes. utf8mb4 stores up to 4 bytes per character, so 191 * 4 = + # 764 is the longest indexable string - 300 produces + # "Specified key was too long" the moment the migration runs on a real + # database, having passed every SQLite test. + slug = db.Column(db.String(191), nullable=False, unique=True, index=True) # One line, shown in the list and in search results. summary = db.Column(db.String(1000)) @@ -114,11 +119,12 @@ class Guide(BaseModel): use_alter=True, name='fk_guides_publishedrevision')) + # createddate / modifieddate / isactive come from BaseModel - no second set + # of timestamps on top of them, or the next person has to work out which + # pair is authoritative. publishedat is ours because "when did readers start + # seeing this" is a different fact from "when was the row last touched". 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) # The working copy's version number. Bumped only when a save actually @@ -161,8 +167,10 @@ class Guide(BaseModel): '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, + # API names stay createdat/updatedat: the column names are a + # storage detail, and renaming a payload field breaks callers. + 'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None, + 'updatedat': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None, 'publishedat': (self.publishedat.isoformat() + 'Z' if self.publishedat else None), } @@ -212,7 +220,6 @@ class GuideRevision(BaseModel): note = db.Column(db.String(500)) createdby = db.Column(db.Integer, db.ForeignKey('users.userid')) - createdat = db.Column(db.DateTime, nullable=False, default=_utcnow) @property def blocks(self): @@ -234,7 +241,7 @@ class GuideRevision(BaseModel): 'tags': [t.strip() for t in (self.tags or '').split(',') if t.strip()], 'note': self.note, 'createdby': self.createdby, - 'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None, + 'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None, 'shorthash': (self.contenthash or '')[:12], } if includeblocks: @@ -268,7 +275,6 @@ class GuideImage(BaseModel): 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 { @@ -278,5 +284,5 @@ class GuideImage(BaseModel): 'mimetype': self.mimetype, 'bytesize': self.bytesize, 'url': '/api/guides/images/{}'.format(self.guideimageid), - 'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None, + 'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None, } diff --git a/plugins/guides/services/authoring.py b/plugins/guides/services/authoring.py index d645d2f0..1afd4389 100644 --- a/plugins/guides/services/authoring.py +++ b/plugins/guides/services/authoring.py @@ -52,7 +52,6 @@ def save(guide, title=None, summary=None, tags=None, blocks=None, note=None, guide.revision = (latest.revision + 1) if latest else 1 guide.updatedby = userid - guide.updatedat = _utcnow() revision = GuideRevision( guideid=guide.guideid,