max / makenotwork
7 files changed,
+132 insertions,
-0 deletions
| @@ -0,0 +1,34 @@ | |||
| 1 | + | -- Expand tag taxonomy for app content seeding. | |
| 2 | + | -- Adds platform, tool-specific, and format tags under existing categories. | |
| 3 | + | -- path = materialized dot-notation (parent_slug.child_slug). | |
| 4 | + | ||
| 5 | + | -- Software children (new) | |
| 6 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 7 | + | ('Desktop', 'desktop', (SELECT id FROM tags WHERE slug = 'software'), 3, 'software.desktop'), | |
| 8 | + | ('macOS', 'macos', (SELECT id FROM tags WHERE slug = 'software'), 4, 'software.macos'), | |
| 9 | + | ('DAW', 'daw', (SELECT id FROM tags WHERE slug = 'software'), 5, 'software.daw'), | |
| 10 | + | ('CLAP', 'clap', (SELECT id FROM tags WHERE slug = 'software'), 6, 'software.clap'), | |
| 11 | + | ('VST3', 'vst3', (SELECT id FROM tags WHERE slug = 'software'), 7, 'software.vst3'); | |
| 12 | + | ||
| 13 | + | -- Audio children (new) | |
| 14 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 15 | + | ('Samples', 'samples', (SELECT id FROM tags WHERE slug = 'audio'), 3, 'audio.samples'), | |
| 16 | + | ('Music Production', 'music-production', (SELECT id FROM tags WHERE slug = 'audio'), 4, 'audio.music-production'); | |
| 17 | + | ||
| 18 | + | -- Topics children (new) | |
| 19 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 20 | + | ('Tasks', 'tasks', (SELECT id FROM tags WHERE slug = 'topics'), 3, 'topics.tasks'), | |
| 21 | + | ('Email', 'email', (SELECT id FROM tags WHERE slug = 'topics'), 4, 'topics.email'), | |
| 22 | + | ('Calendar', 'calendar', (SELECT id FROM tags WHERE slug = 'topics'), 5, 'topics.calendar'), | |
| 23 | + | ('RSS', 'rss', (SELECT id FROM tags WHERE slug = 'topics'), 6, 'topics.rss'), | |
| 24 | + | ('Feeds', 'feeds', (SELECT id FROM tags WHERE slug = 'topics'), 7, 'topics.feeds'), | |
| 25 | + | ('Reader', 'reader', (SELECT id FROM tags WHERE slug = 'topics'), 8, 'topics.reader'), | |
| 26 | + | ('Platform', 'platform', (SELECT id FROM tags WHERE slug = 'topics'), 9, 'topics.platform'), | |
| 27 | + | ('Development', 'development', (SELECT id FROM tags WHERE slug = 'topics'), 10, 'topics.development'); | |
| 28 | + | ||
| 29 | + | -- New top-level category: Technology | |
| 30 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 31 | + | ('Technology', 'technology', NULL, 4, 'technology'); | |
| 32 | + | ||
| 33 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 34 | + | ('Rust', 'rust', (SELECT id FROM tags WHERE slug = 'technology'), 0, 'technology.rust'); |
| @@ -31,6 +31,8 @@ const ALLOWED_ARCHS: &[&str] = &["x86_64", "aarch64"]; | |||
| 31 | 31 | ||
| 32 | 32 | /// Validate an app slug: 3-40 chars, lowercase alphanumeric + hyphens, | |
| 33 | 33 | /// no leading/trailing hyphens. | |
| 34 | + | /// | |
| 35 | + | /// Also exposed as `validate_slug_public` for the session-auth slug endpoint. | |
| 34 | 36 | fn validate_slug(slug: &str) -> Result<()> { | |
| 35 | 37 | if slug.len() < 3 || slug.len() > 40 { | |
| 36 | 38 | return Err(AppError::BadRequest( | |
| @@ -457,3 +459,8 @@ pub fn ota_routes() -> Router<AppState> { | |||
| 457 | 459 | ||
| 458 | 460 | mgmt_routes.merge(public_routes) | |
| 459 | 461 | } | |
| 462 | + | ||
| 463 | + | /// Public slug validation for use by session-auth endpoints. | |
| 464 | + | pub fn validate_slug_public(slug: &str) -> Result<()> { | |
| 465 | + | validate_slug(slug) | |
| 466 | + | } |
| @@ -584,6 +584,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_synckit( | |||
| 584 | 584 | device_count, | |
| 585 | 585 | log_entry_count, | |
| 586 | 586 | created_at: app.created_at.format("%b %d, %Y").to_string(), | |
| 587 | + | slug: app.slug.clone(), | |
| 587 | 588 | project_name, | |
| 588 | 589 | project_slug, | |
| 589 | 590 | item_title, |
| @@ -14,6 +14,8 @@ use crate::{ | |||
| 14 | 14 | AppState, | |
| 15 | 15 | }; | |
| 16 | 16 | ||
| 17 | + | use super::UpdateAppSlugRequest; | |
| 18 | + | ||
| 17 | 19 | use super::{CreateAppRequest, UpdateAppLinkRequest}; | |
| 18 | 20 | ||
| 19 | 21 | /// Create a new sync app and generate its API key. | |
| @@ -123,6 +125,32 @@ pub(super) async fn update_app_link( | |||
| 123 | 125 | Ok(Json(updated)) | |
| 124 | 126 | } | |
| 125 | 127 | ||
| 128 | + | /// Set the OTA slug for a sync app. | |
| 129 | + | /// | |
| 130 | + | /// `PUT /api/sync/apps/{id}/slug` -- Session auth required. | |
| 131 | + | #[tracing::instrument(skip_all, name = "synckit::update_app_slug")] | |
| 132 | + | pub(super) async fn update_app_slug( | |
| 133 | + | State(state): State<AppState>, | |
| 134 | + | AuthUser(user): AuthUser, | |
| 135 | + | Path(app_id): Path<SyncAppId>, | |
| 136 | + | Json(req): Json<UpdateAppSlugRequest>, | |
| 137 | + | ) -> Result<impl IntoResponse> { | |
| 138 | + | let app = db::synckit::get_sync_app_by_id(&state.db, app_id) | |
| 139 | + | .await? | |
| 140 | + | .ok_or(AppError::NotFound)?; | |
| 141 | + | ||
| 142 | + | if app.creator_id != user.id { | |
| 143 | + | return Err(AppError::Forbidden); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Reuse the OTA slug validation | |
| 147 | + | crate::routes::ota::validate_slug_public(&req.slug)?; | |
| 148 | + | ||
| 149 | + | db::ota::set_app_slug(&state.db, app_id, &req.slug).await?; | |
| 150 | + | ||
| 151 | + | Ok(axum::http::StatusCode::NO_CONTENT) | |
| 152 | + | } | |
| 153 | + | ||
| 126 | 154 | // ── Link helpers ── | |
| 127 | 155 | ||
| 128 | 156 | /// Parse an optional UUID string and verify the project belongs to the user. |
| @@ -127,6 +127,11 @@ pub struct UpdateAppLinkRequest { | |||
| 127 | 127 | pub item_id: Option<String>, | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | + | #[derive(Deserialize)] | |
| 131 | + | pub struct UpdateAppSlugRequest { | |
| 132 | + | pub slug: String, | |
| 133 | + | } | |
| 134 | + | ||
| 130 | 135 | #[derive(Serialize)] | |
| 131 | 136 | pub(super) struct SyncStatusResponse { | |
| 132 | 137 | total_changes: i64, | |
| @@ -228,6 +233,7 @@ pub fn synckit_routes() -> Router<AppState> { | |||
| 228 | 233 | .route("/api/sync/apps", get(apps::list_apps)) | |
| 229 | 234 | .route("/api/sync/apps/{id}/regenerate-key", post(apps::regenerate_app_key)) | |
| 230 | 235 | .route("/api/sync/apps/{id}/link", put(apps::update_app_link)) | |
| 236 | + | .route("/api/sync/apps/{id}/slug", put(apps::update_app_slug)) | |
| 231 | 237 | .route("/api/sync/apps/{id}", delete(apps::delete_app)); | |
| 232 | 238 | ||
| 233 | 239 | auth_routes.merge(sync_routes).merge(app_routes) |
| @@ -414,6 +414,7 @@ pub struct SyncAppRow { | |||
| 414 | 414 | pub device_count: i64, | |
| 415 | 415 | pub log_entry_count: i64, | |
| 416 | 416 | pub created_at: String, | |
| 417 | + | pub slug: Option<String>, | |
| 417 | 418 | pub project_name: Option<String>, | |
| 418 | 419 | pub project_slug: Option<String>, | |
| 419 | 420 | pub item_title: Option<String>, |
| @@ -40,6 +40,7 @@ | |||
| 40 | 40 | <thead> | |
| 41 | 41 | <tr> | |
| 42 | 42 | <th>Name</th> | |
| 43 | + | <th>OTA Slug</th> | |
| 43 | 44 | <th>Linked To</th> | |
| 44 | 45 | <th>API Key</th> | |
| 45 | 46 | <th>Status</th> | |
| @@ -53,6 +54,16 @@ | |||
| 53 | 54 | {% for app in apps %} | |
| 54 | 55 | <tr> | |
| 55 | 56 | <td>{{ app.name }}</td> | |
| 57 | + | <td id="synckit-slug-cell-{{ app.id }}"> | |
| 58 | + | {% if let Some(s) = app.slug %} | |
| 59 | + | <code style="font-size: 0.8rem;">{{ s }}</code> | |
| 60 | + | {% else %} | |
| 61 | + | <span class="dimmed">—</span> | |
| 62 | + | {% endif %} | |
| 63 | + | <button class="btn-small secondary" | |
| 64 | + | onclick="syncKitShowSlugForm('{{ app.id }}', '{{ app.slug.as_deref().unwrap_or_default() }}')" | |
| 65 | + | style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;">Set</button> | |
| 66 | + | </td> | |
| 56 | 67 | <td id="synckit-link-cell-{{ app.id }}"> | |
| 57 | 68 | {% if let Some(pname) = app.project_name %} | |
| 58 | 69 | {% if let Some(pslug) = app.project_slug %} | |
| @@ -143,6 +154,50 @@ function syncKitDeleteApp(appId, name) { | |||
| 143 | 154 | }); | |
| 144 | 155 | } | |
| 145 | 156 | ||
| 157 | + | function syncKitShowSlugForm(appId, currentSlug) { | |
| 158 | + | var cell = document.getElementById('synckit-slug-cell-' + appId); | |
| 159 | + | if (!cell) return; | |
| 160 | + | cell.innerHTML = ''; | |
| 161 | + | var input = document.createElement('input'); | |
| 162 | + | input.type = 'text'; | |
| 163 | + | input.value = currentSlug; | |
| 164 | + | input.placeholder = 'e.g. goingson'; | |
| 165 | + | input.style.cssText = 'padding: 0.3rem; font-size: 0.85rem; width: 120px;'; | |
| 166 | + | cell.appendChild(input); | |
| 167 | + | cell.appendChild(document.createTextNode(' ')); | |
| 168 | + | var saveBtn = document.createElement('button'); | |
| 169 | + | saveBtn.className = 'btn-small'; | |
| 170 | + | saveBtn.style.cssText = 'padding: 0.15rem 0.4rem; font-size: 0.75rem;'; | |
| 171 | + | saveBtn.textContent = 'Save'; | |
| 172 | + | saveBtn.addEventListener('click', function() { | |
| 173 | + | var slug = input.value.trim(); | |
| 174 | + | if (!slug) return; | |
| 175 | + | fetch('/api/sync/apps/' + appId + '/slug', { | |
| 176 | + | method: 'PUT', | |
| 177 | + | credentials: 'same-origin', | |
| 178 | + | headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, | |
| 179 | + | body: JSON.stringify({ slug: slug }) | |
| 180 | + | }).then(function(res) { | |
| 181 | + | if (res.ok) { | |
| 182 | + | document.getElementById('tab-synckit').click(); | |
| 183 | + | showToast('OTA slug set to "' + slug + '".'); | |
| 184 | + | } else { | |
| 185 | + | res.text().then(function(t) { showToast(t || 'Failed to set slug.'); }); | |
| 186 | + | } | |
| 187 | + | }).catch(function() { | |
| 188 | + | showToast('Network error. Please check your connection and try again.'); | |
| 189 | + | }); | |
| 190 | + | }); | |
| 191 | + | cell.appendChild(saveBtn); | |
| 192 | + | cell.appendChild(document.createTextNode(' ')); | |
| 193 | + | var cancelBtn = document.createElement('button'); | |
| 194 | + | cancelBtn.className = 'btn-small secondary'; | |
| 195 | + | cancelBtn.style.cssText = 'padding: 0.15rem 0.4rem; font-size: 0.75rem;'; | |
| 196 | + | cancelBtn.textContent = 'Cancel'; | |
| 197 | + | cancelBtn.addEventListener('click', function() { document.getElementById('tab-synckit').click(); }); | |
| 198 | + | cell.appendChild(cancelBtn); | |
| 199 | + | } | |
| 200 | + | ||
| 146 | 201 | function syncKitShowLinkForm(appId) { | |
| 147 | 202 | var cell = document.getElementById('synckit-link-cell-' + appId); | |
| 148 | 203 | if (!cell) return; |