textplus/Sources/PostgresStore.swift
monster 09972ccde1 TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board
A no-Xcode AppKit app (swiftc + build.sh):
- Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find,
  live Markdown preview (split mode), selection-aware Markdown toolbar.
- Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape,
  US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel),
  freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich
  status bar. The Editor follows the selected page; layout persists to a
  foo.txt.board sidecar; the .txt stays pure plaintext.
- scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain).
- HTML scraping pipeline: custom selector engine + Discogs profile, ingests to
  Postgres (psql) into a marketplace_snapshots schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:08:59 +10:00

429 lines
18 KiB
Swift

import Foundation
import CryptoKit
enum PGSettings {
static let autoIngestKey = "pgAutoIngest"
static let uriKey = "pgURI"
static let defaultURI = "postgresql://localhost:5433/scraperrr"
static var autoIngest: Bool { UserDefaults.standard.bool(forKey: autoIngestKey) }
static var uri: String {
let v = (UserDefaults.standard.string(forKey: uriKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return v.isEmpty ? defaultURI : v
}
}
struct PGError: Error {
let message: String
}
/// Talks to Postgres by piping SQL into `psql` no client library to link.
/// All values are escaped as SQL literals; numeric ids are validated in Swift.
enum PostgresStore {
static var psqlPath: String? {
let candidates = [
"/opt/homebrew/opt/postgresql@17/bin/psql",
"/opt/homebrew/opt/postgresql@18/bin/psql",
"/opt/homebrew/opt/postgresql@16/bin/psql",
"/opt/homebrew/bin/psql",
"/usr/local/bin/psql",
"/usr/bin/psql",
]
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
}
struct PageIngest {
let url: String?
let sourceName: String
let contentHash: String
let elementCount: Int
let selectors: [(selector: String, count: Int, sample: String)]
let profileName: String?
let header: [String]
let rows: [[String]]
}
struct IngestReport {
let pageId: Int64
let selectorCount: Int
let rowCount: Int
let snapshotCount: Int
let skippedRows: Int
let skippedDuplicate: Bool
}
static func sha256(_ s: String) -> String {
SHA256.hash(data: Data(s.utf8)).map { String(format: "%02x", $0) }.joined()
}
// MARK: - Public entry points (call from main; completions on main)
static func test(uri: String, completion: @escaping (Result<String, PGError>) -> Void) {
let sql = schemaSQL + "\nSELECT current_database() || ' · ' || split_part(version(), ' on ', 1);"
runSQL(uri: uri, sql: sql) { result in
switch result {
case .success(let out):
let last = out.split(separator: "\n").last.map(String.init) ?? "ok"
completion(.success("Connected, tables ready — \(last)"))
case .failure(let error):
completion(.failure(error))
}
}
}
static func ingest(_ page: PageIngest, force: Bool,
completion: @escaping (Result<IngestReport, PGError>) -> Void) {
let uri = PGSettings.uri
func runMainIngest() {
let built = buildIngestSQL(page)
runSQL(uri: uri, sql: built.sql) { result in
switch result {
case .success(let out):
guard let idLine = out.split(separator: "\n").last,
let pageId = Int64(idLine.trimmingCharacters(in: .whitespaces)) else {
completion(.failure(PGError(message: "Couldn't read the new page id from psql output: \(out.suffix(120))")))
return
}
completion(.success(IngestReport(pageId: pageId,
selectorCount: page.selectors.count,
rowCount: page.rows.count,
snapshotCount: built.snapshotCount,
skippedRows: built.skipped,
skippedDuplicate: false)))
case .failure(let error):
completion(.failure(error))
}
}
}
if force || page.url == nil {
runMainIngest()
return
}
// Auto mode: skip if the latest capture of this URL has identical content.
let checkSQL = schemaSQL + """
SELECT content_hash FROM tp_pages WHERE url = \(lit(page.url))
ORDER BY captured_at DESC LIMIT 1;
"""
runSQL(uri: uri, sql: checkSQL) { result in
switch result {
case .success(let out):
let lastHash = out.split(separator: "\n").last.map(String.init) ?? ""
if lastHash == page.contentHash {
completion(.success(IngestReport(pageId: -1, selectorCount: 0, rowCount: 0,
snapshotCount: 0, skippedRows: 0, skippedDuplicate: true)))
} else {
runMainIngest()
}
case .failure(let error):
completion(.failure(error))
}
}
}
// MARK: - SQL construction
private static let schemaSQL = """
CREATE TABLE IF NOT EXISTS tp_pages (
id BIGSERIAL PRIMARY KEY,
url TEXT,
source_name TEXT,
content_hash TEXT,
element_count INT,
selector_count INT,
captured_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tp_selectors (
id BIGSERIAL PRIMARY KEY,
page_id BIGINT REFERENCES tp_pages(id) ON DELETE CASCADE,
selector TEXT NOT NULL,
match_count INT NOT NULL,
sample TEXT
);
CREATE TABLE IF NOT EXISTS tp_rows (
id BIGSERIAL PRIMARY KEY,
page_id BIGINT REFERENCES tp_pages(id) ON DELETE CASCADE,
profile TEXT,
row_num INT,
fields JSONB
);
CREATE INDEX IF NOT EXISTS idx_tp_pages_url ON tp_pages(url);
CREATE INDEX IF NOT EXISTS idx_tp_rows_page ON tp_rows(page_id);
CREATE TABLE IF NOT EXISTS discogs_sellers (
seller_id BIGINT PRIMARY KEY,
username VARCHAR(100) NOT NULL,
last_scraped_at TIMESTAMPTZ DEFAULT now()
);
-- A username can be recycled across seller_ids over time; the seller_id is
-- the real key. Drop the old UNIQUE(username) so a reused name can't abort
-- a whole page ingest. (IF NOT EXISTS skips the table on existing installs,
-- so the constraint must be dropped explicitly.)
ALTER TABLE discogs_sellers DROP CONSTRAINT IF EXISTS discogs_sellers_username_key;
CREATE TABLE IF NOT EXISTS marketplace_snapshots (
item_id BIGINT PRIMARY KEY,
release_id BIGINT NOT NULL,
seller_id BIGINT REFERENCES discogs_sellers(seller_id),
title TEXT,
listing_url TEXT,
media_condition VARCHAR(120),
sleeve_condition VARCHAR(120),
price_currency VARCHAR(10),
price_value NUMERIC(12,2),
shipping_value NUMERIC(12,2),
first_seen TIMESTAMPTZ DEFAULT now(),
last_seen TIMESTAMPTZ DEFAULT now(),
is_active BOOLEAN DEFAULT TRUE
);
CREATE INDEX IF NOT EXISTS idx_market_release_id ON marketplace_snapshots(release_id);
CREATE INDEX IF NOT EXISTS idx_market_seller_active ON marketplace_snapshots(seller_id) WHERE is_active = TRUE;
"""
private static func buildIngestSQL(_ page: PageIngest) -> (sql: String, snapshotCount: Int, skipped: Int) {
var sql = schemaSQL + "\nBEGIN;\n"
sql += """
INSERT INTO tp_pages (url, source_name, content_hash, element_count, selector_count)
VALUES (\(lit(page.url)), \(lit(page.sourceName)), \(lit(page.contentHash)), \(page.elementCount), \(page.selectors.count))
RETURNING id AS page_id \\gset
"""
for chunk in page.selectors.chunked(into: 500) {
let values = chunk.map {
"(:page_id, \(lit($0.selector)), \($0.count), \(lit(String($0.sample.prefix(300)))))"
}.joined(separator: ",\n")
sql += "INSERT INTO tp_selectors (page_id, selector, match_count, sample) VALUES\n\(values);\n"
}
if !page.rows.isEmpty, let profile = page.profileName {
for (chunkIndex, chunk) in page.rows.chunked(into: 200).enumerated() {
let values = chunk.enumerated().map { offset, row -> String in
let rowNum = chunkIndex * 200 + offset + 1
var dict: [String: String] = [:]
for (i, name) in page.header.enumerated() where i < row.count {
// Condition fields capture the grade plus its tooltip
// description; keep the JSONB mirror as clean as the
// snapshot column (grade through the closing paren).
if name.hasSuffix("_condition"), let close = row[i].range(of: ")") {
dict[name] = String(row[i][..<close.upperBound])
} else {
dict[name] = row[i]
}
}
let json = (try? JSONSerialization.data(withJSONObject: dict))
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
return "(:page_id, \(lit(profile)), \(rowNum), \(lit(json))::jsonb)"
}.joined(separator: ",\n")
sql += "INSERT INTO tp_rows (page_id, profile, row_num, fields) VALUES\n\(values);\n"
}
}
var snapshotCount = 0
var skipped = 0
if page.profileName == ScrapeProfile.discogsMarketplace.name {
let upserts = buildDiscogsUpserts(header: page.header, rows: page.rows)
sql += upserts.sql
snapshotCount = upserts.snapshotCount
skipped = upserts.skipped
}
sql += "COMMIT;\nSELECT :page_id;\n"
return (sql, snapshotCount, skipped)
}
/// Maps profile rows onto the scraperrr snapshot schema:
/// sellers upserted by data-seller-id, listings upserted by data-item-id
/// with last_seen/price refreshed on re-capture.
private static func buildDiscogsUpserts(header: [String], rows: [[String]]) -> (sql: String, snapshotCount: Int, skipped: Int) {
func col(_ name: String) -> Int? { header.firstIndex(of: name) }
guard let itemIdx = col("item_id"), let relIdx = col("release_id") else { return ("", 0, 0) }
let sellerIdIdx = col("seller_id"), sellerNameIdx = col("seller_username")
let sellerNameAltIdx = col("seller_username_alt")
let titleIdx = col("title"), urlIdx = col("listing_url")
let mediaIdx = col("media_condition"), sleeveIdx = col("sleeve_condition")
let curIdx = col("price_currency"), priceIdx = col("price_value")
let shipIdx = col("shipping")
func value(_ row: [String], _ idx: Int?) -> String? {
guard let idx, idx < row.count else { return nil }
let v = row[idx].trimmingCharacters(in: .whitespaces)
return v.isEmpty ? nil : v
}
// "Near Mint (NM or M-) A nearly perfect record" keep through the ")".
func condition(_ s: String?) -> String? {
guard let s else { return nil }
if let close = s.range(of: ")") { return String(s[..<close.upperBound]) }
return String(s.prefix(60))
}
func numeric(_ s: String?) -> String? {
guard let s, let d = Double(s) else { return nil }
return String(format: "%.2f", d)
}
func firstDecimal(_ s: String?) -> String? {
guard let s, let r = s.range(of: #"\d+(\.\d+)?"#, options: .regularExpression) else { return nil }
return numeric(String(s[r]))
}
var sellers: [Int64: String] = [:]
var snapshots: [Int64: String] = [:] // item_id VALUES tuple (dedup within batch)
var skipped = 0
for row in rows {
guard let itemId = value(row, itemIdx).flatMap({ Int64($0) }),
let releaseId = value(row, relIdx).flatMap({ Int64($0) }) else {
skipped += 1
continue
}
// Only reference a seller_id the snapshot's FK can resolve: upsert
// the seller whenever its id is present (falling back to the
// co-located data-username, then a derived name), and NULL the
// snapshot's seller_id when no id was found.
var linkedSellerId: Int64?
if let sellerId = value(row, sellerIdIdx).flatMap({ Int64($0) }) {
let name = value(row, sellerNameIdx)
?? value(row, sellerNameAltIdx)
?? "seller_\(sellerId)"
sellers[sellerId] = name
linkedSellerId = sellerId
}
let tuple = """
(\(itemId), \(releaseId), \(linkedSellerId.map(String.init) ?? "NULL"), \
\(lit(value(row, titleIdx))), \(lit(value(row, urlIdx))), \
\(lit(condition(value(row, mediaIdx)))), \(lit(condition(value(row, sleeveIdx)))), \
\(lit(value(row, curIdx))), \(numeric(value(row, priceIdx)) ?? "NULL"), \
\(firstDecimal(value(row, shipIdx)) ?? "NULL"))
"""
snapshots[itemId] = tuple
}
guard !snapshots.isEmpty else { return ("", 0, skipped) }
var sql = ""
if !sellers.isEmpty {
let sellerValues = sellers
.sorted { $0.key < $1.key }
.map { "(\($0.key), \(lit(String($0.value.prefix(100)))))" }
.joined(separator: ",\n")
sql += """
INSERT INTO discogs_sellers (seller_id, username) VALUES
\(sellerValues)
ON CONFLICT (seller_id) DO UPDATE SET username = EXCLUDED.username, last_scraped_at = now();
"""
}
let snapshotValues = snapshots.sorted { $0.key < $1.key }.map { $0.value }.joined(separator: ",\n")
sql += """
INSERT INTO marketplace_snapshots
(item_id, release_id, seller_id, title, listing_url, media_condition,
sleeve_condition, price_currency, price_value, shipping_value) VALUES
\(snapshotValues)
ON CONFLICT (item_id) DO UPDATE SET
last_seen = now(),
price_value = EXCLUDED.price_value,
shipping_value = EXCLUDED.shipping_value,
media_condition = EXCLUDED.media_condition,
sleeve_condition = EXCLUDED.sleeve_condition,
is_active = TRUE;
"""
return (sql, snapshots.count, skipped)
}
private static func lit(_ s: String?) -> String {
guard let s else { return "NULL" }
let cleaned = s.replacingOccurrences(of: "\0", with: "")
return "'" + cleaned.replacingOccurrences(of: "'", with: "''") + "'"
}
// MARK: - psql runner
private static let workQueue = DispatchQueue(label: "textplus.postgres")
private static let timeoutSeconds: TimeInterval = 60
private static func runSQL(uri: String, sql: String,
completion: @escaping (Result<String, PGError>) -> Void) {
guard let psql = psqlPath else {
DispatchQueue.main.async {
completion(.failure(PGError(message: "psql not found — install with: brew install postgresql@17")))
}
return
}
workQueue.async {
let process = Process()
process.executableURL = URL(fileURLWithPath: psql)
process.arguments = ["-X", "-q", "-A", "-t", "-v", "ON_ERROR_STOP=1", uri]
let inPipe = Pipe(), outPipe = Pipe(), errPipe = Pipe()
process.standardInput = inPipe
process.standardOutput = outPipe
process.standardError = errPipe
do {
try process.run()
} catch {
DispatchQueue.main.async { completion(.failure(PGError(message: error.localizedDescription))) }
return
}
let watchdog = DispatchWorkItem { [weak process] in
guard let process, process.isRunning else { return }
process.terminate()
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeoutSeconds, execute: watchdog)
// Drain stdout/stderr concurrently while we feed stdin, so a large
// script can't deadlock on a full pipe in either direction.
var outData = Data()
var errData = Data()
let group = DispatchGroup()
group.enter()
DispatchQueue.global(qos: .utility).async {
outData = outPipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}
group.enter()
DispatchQueue.global(qos: .utility).async {
errData = errPipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}
let writer = inPipe.fileHandleForWriting
do {
try writer.write(contentsOf: Data(sql.utf8))
try writer.close()
} catch {
// Broken pipe (psql died early) fall through to read its stderr.
try? writer.close()
}
group.wait()
process.waitUntilExit()
watchdog.cancel()
let out = String(decoding: outData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
let err = String(decoding: errData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
let status = process.terminationStatus
DispatchQueue.main.async {
if status == 0 {
completion(.success(out))
} else {
let detail = err.isEmpty ? "psql exited with status \(status)" : err
completion(.failure(PGError(message: detail)))
}
}
}
}
}
private extension Array {
func chunked(into size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
}
}