Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions InterlinedList/Models/GitHubMetadata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// GitHubMetadata.swift
// InterlinedList
//

import Foundation

/// Repo metadata used to fill in GitHub-backed list rows: the repo's real labels
/// and assignable users, the orgs the linked account belongs to, and the next
/// issue number.
///
/// `/api/github/repos/:owner/:repo/{labels,assignees}` forward the **raw GitHub
/// REST arrays**, so these decode through `APIClient`'s `convertFromSnakeCase`
/// decoder (`avatar_url` → `avatarUrl`). Decode defensively: only the identifying
/// field is required.

struct GitHubLabel: Decodable, Identifiable, Hashable {
let name: String
let color: String?
let description: String?

var id: String { name }
}

struct GitHubAssignee: Decodable, Identifiable, Hashable {
let login: String
let avatarUrl: String?

var id: String { login }
}

struct GitHubOrg: Decodable, Identifiable, Hashable {
let login: String
let avatarUrl: String?

var id: String { login }
}

/// `GET /api/github/orgs` has been observed returning **both** a bare array and
/// an `{ orgs: [...] }` envelope depending on deployment, so accept either.
struct GitHubOrgsResponse: Decodable {
let orgs: [GitHubOrg]

init(from decoder: Decoder) throws {
if let container = try? decoder.singleValueContainer(),
let list = try? container.decode([GitHubOrg].self) {
orgs = list
return
}
let keyed = try decoder.container(keyedBy: CodingKeys.self)
orgs = try keyed.decodeIfPresent([GitHubOrg].self, forKey: .orgs) ?? []
}

private enum CodingKeys: String, CodingKey { case orgs }
}

struct GitHubNextIssueNumber: Decodable {
let nextNumber: Int
}
185 changes: 185 additions & 0 deletions InterlinedList/Models/Materialize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//
// Materialize.swift
// InterlinedList
//

import Foundation

/// Wire types for "Create from…" (`POST /api/materialize`), which turns an
/// existing object — message(s), list(s), list rows, or a document — into a new
/// List, a new Document, or both.
///
/// The request carries **id-only references**. The server re-fetches and
/// authorizes every referenced id under the calling user and re-derives each
/// column's values from that authoritative data via `sourceKey`; client-supplied
/// cell values are never trusted. So the preview this app renders is advisory —
/// the server's output is the real thing.
///
/// The body is camelCase, so it must go out through `postCamel`, not `post`.

enum MaterializeTarget: String, Codable, CaseIterable, Identifiable {
case list
case doc
case both

var id: String { rawValue }

var label: String {
switch self {
case .list: return "To List"
case .doc: return "To Doc"
case .both: return "To List & Doc"
}
}

var createsList: Bool { self == .list || self == .both }
var createsDocument: Bool { self == .doc || self == .both }
}

/// The id-only source reference sent over the wire.
///
/// The server also accepts a `docElements` kind (a document id plus selected
/// markdown), used by the web editor's text-selection menu. iOS has no
/// equivalent selection affordance yet, so it is deliberately not modeled here.
enum MaterializeSourceRef: Equatable {
case messages(messageIds: [String])
case lists(listIds: [String])
case rows(listId: String, rowIds: [String])
case document(documentId: String)
}

extension MaterializeSourceRef: Encodable {
private enum CodingKeys: String, CodingKey {
case kind, messageIds, listIds, listId, rowIds, documentId
}

func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .messages(let ids):
try c.encode("messages", forKey: .kind)
try c.encode(ids, forKey: .messageIds)
case .lists(let ids):
try c.encode("lists", forKey: .kind)
try c.encode(ids, forKey: .listIds)
case .rows(let listId, let rowIds):
try c.encode("rows", forKey: .kind)
try c.encode(listId, forKey: .listId)
try c.encode(rowIds, forKey: .rowIds)
case .document(let documentId):
try c.encode("document", forKey: .kind)
try c.encode(documentId, forKey: .documentId)
}
}
}

/// One column in the finalize sheet's schema editor.
///
/// `sourceKey` maps the column back to a source attribute so the **server** can
/// re-derive its values. `nil` means a user-added empty column and is encoded as
/// an explicit `null` (not omitted) to match the documented contract.
struct MaterializeFieldConfig: Identifiable, Equatable {
var propertyKey: String
var propertyName: String
var propertyType: String
var isRequired: Bool?
var options: [String]?
var sourceKey: String?

var id: String { propertyKey }

/// Column types offered by the editor. `textarea` is included because the
/// server's inferred schemas use it for long-form columns (message content,
/// document text).
static let supportedTypes: [String] = [
"text", "textarea", "number", "boolean", "date", "url", "email",
]

init(propertyKey: String,
propertyName: String,
propertyType: String,
isRequired: Bool? = nil,
options: [String]? = nil,
sourceKey: String?) {
self.propertyKey = propertyKey
self.propertyName = propertyName
self.propertyType = propertyType
self.isRequired = isRequired
self.options = options
self.sourceKey = sourceKey
}
}

extension MaterializeFieldConfig: Encodable {
private enum CodingKeys: String, CodingKey {
case propertyKey, propertyName, propertyType, isRequired, options, sourceKey
}

func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(propertyKey, forKey: .propertyKey)
try c.encode(propertyName, forKey: .propertyName)
try c.encode(propertyType, forKey: .propertyType)
try c.encodeIfPresent(isRequired, forKey: .isRequired)
try c.encodeIfPresent(options, forKey: .options)
if let sourceKey {
try c.encode(sourceKey, forKey: .sourceKey)
} else {
try c.encodeNil(forKey: .sourceKey)
}
}
}

struct MaterializeListConfig: Encodable, Equatable {
var title: String
var description: String?
var isPublic: Bool?
var fields: [MaterializeFieldConfig]
/// Defaults to `true` server-side; only an explicit `false` creates an
/// empty, columns-only list.
var includeData: Bool?
}

enum MaterializeDocListStyle: String, Codable, CaseIterable, Identifiable {
case numbered
case bulleted

var id: String { rawValue }
var label: String { rawValue.capitalized }
}

enum MaterializeDocRowDataStyle: String, Codable, CaseIterable, Identifiable {
case inline
case subItems = "sub-items"

var id: String { rawValue }
var label: String { self == .inline ? "Inline" : "Sub-items" }
}

struct MaterializeDocConfig: Encodable, Equatable {
var title: String
/// Omitted so the server derives its canonical path (it appends `.md` and
/// slugs from the source). Set only when the user supplies one.
var relativePath: String?
var isPublic: Bool?
/// Only meaningful for list / row sources.
var listStyle: MaterializeDocListStyle?
var rowDataStyle: MaterializeDocRowDataStyle?
}

struct MaterializeRequest: Encodable {
let target: MaterializeTarget
let source: MaterializeSourceRef
let listConfig: MaterializeListConfig?
let docConfig: MaterializeDocConfig?
}

struct MaterializeCreatedRef: Decodable, Equatable {
let id: String
let title: String
}

struct MaterializeResult: Decodable, Equatable {
let list: MaterializeCreatedRef?
let document: MaterializeCreatedRef?
}
50 changes: 50 additions & 0 deletions InterlinedList/Services/APIClient+GitHub.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// APIClient+GitHub.swift
// InterlinedList
//

import Foundation

/// GitHub repo metadata (`/api/github/*`, Bearer). These routes resolve the user
/// through `getGitHubIssuesContext`, which accepts a Bearer sync token, then call
/// GitHub with the linked account's token. They return **400 "GitHub account not
/// linked"** — not 401 — when no GitHub identity is linked, so callers should
/// degrade to free-text entry rather than treat it as an auth failure.
///
/// Issue rows themselves are still written through the standard
/// `/api/lists/:id/data` proxy (`updateItem`, full row) — never through GitHub's
/// issue routes directly — because the backend rebuilds the issue from the row it
/// receives and defaults a missing `title` to "Untitled".
extension APIClient {
/// Labels defined on a repo (`GET /api/github/repos/:owner/:repo/labels`).
func githubLabels(owner: String, repo: String) async throws -> [GitHubLabel] {
try await get("/api/github/repos/\(escape(owner))/\(escape(repo))/labels")
}

/// Users assignable to the repo's issues
/// (`GET /api/github/repos/:owner/:repo/assignees`).
func githubAssignees(owner: String, repo: String) async throws -> [GitHubAssignee] {
try await get("/api/github/repos/\(escape(owner))/\(escape(repo))/assignees")
}

/// Organizations the linked GitHub account belongs to (`GET /api/github/orgs`).
/// Best-effort on the server: private memberships appear only when the
/// connection carried the `read:org` scope.
func githubOrgs() async throws -> [GitHubOrg] {
let response: GitHubOrgsResponse = try await get("/api/github/orgs")
return response.orgs
}

/// The number GitHub will assign to the next issue in a repo
/// (`GET /api/github/repos/:owner/:repo/next-issue-number`).
func githubNextIssueNumber(owner: String, repo: String) async throws -> Int {
let response: GitHubNextIssueNumber = try await get(
"/api/github/repos/\(escape(owner))/\(escape(repo))/next-issue-number"
)
return response.nextNumber
}

private func escape(_ component: String) -> String {
component.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? component
}
}
32 changes: 32 additions & 0 deletions InterlinedList/Services/APIClient+Materialize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// APIClient+Materialize.swift
// InterlinedList
//

import Foundation

extension APIClient {
/// Creates a List, a Document, or both from an existing object
/// (`POST /api/materialize`, Bearer, camelCase body).
///
/// **Subscriber-only** — a free account gets `403` ("Subscribe to create
/// lists and documents."), so callers must hide the affordance rather than
/// surface a paywall. `404` means a referenced id isn't owned by the caller;
/// `400` means a bad config (no title, no columns, unsupported source).
///
/// `listConfig` is **required** whenever `target` creates a list — the server
/// rejects a missing title or an empty column set. `docConfig` is optional;
/// the server derives defaults for anything omitted.
func materialize(target: MaterializeTarget,
source: MaterializeSourceRef,
listConfig: MaterializeListConfig? = nil,
docConfig: MaterializeDocConfig? = nil) async throws -> MaterializeResult {
let body = MaterializeRequest(
target: target,
source: source,
listConfig: target.createsList ? listConfig : nil,
docConfig: target.createsDocument ? docConfig : nil
)
return try await postCamel("/api/materialize", body: body)
}
}
Loading
Loading