initial commit

This commit is contained in:
2026-01-08 19:16:32 +00:00
commit d89aedd5af
121 changed files with 8509 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
//
// FeedStore.swift
// iris
//
// Created by Codex.
//
import Foundation
final class FeedStore {
struct CardKey: Hashable, Codable {
let id: String
let type: WinnerType
}
struct CardState: Codable, Equatable {
var lastShownAt: Int?
var dismissedUntil: Int?
var snoozedUntil: Int?
}
private struct Persisted: Codable {
var feed: FeedEnvelope?
var states: [String: CardState]
}
private let queue = DispatchQueue(label: "iris.feedstore.queue")
private let fileURL: URL
private var cachedFeed: FeedEnvelope?
private var states: [String: CardState] = [:]
init(filename: String = "feed_store_v1.json") {
self.fileURL = Self.defaultFileURL(filename: filename)
let persisted = Self.load(from: fileURL)
self.cachedFeed = persisted?.feed
self.states = persisted?.states ?? [:]
}
func getFeed(now: Int = Int(Date().timeIntervalSince1970)) -> FeedEnvelope {
queue.sync {
guard let feed = cachedFeed else {
return FeedEnvelope.allQuiet(now: now, reason: "no_feed", source: "store")
}
let filtered = normalizedFeed(feed, now: now)
if filtered.feed.isEmpty {
return FeedEnvelope.allQuiet(now: now, reason: "expired_or_suppressed", source: "store")
}
return filtered
}
}
func setFeed(_ feed: FeedEnvelope, now: Int = Int(Date().timeIntervalSince1970)) {
queue.sync {
let normalized = normalizedFeed(feed, now: now, applyingSuppression: false)
cachedFeed = normalized
markShown(feed: normalized, now: now)
save()
}
}
func lastShownAt(candidateId: String) -> Int? {
queue.sync {
let matches = states.compactMap { (key, value) -> Int? in
guard key.hasSuffix("|" + candidateId) else { return nil }
return value.lastShownAt
}
return matches.max()
}
}
func isSuppressed(id: String, type: WinnerType, now: Int) -> Bool {
queue.sync {
let key = Self.keyString(id: id, type: type)
guard let state = states[key] else { return false }
if let until = state.dismissedUntil, until > now { return true }
if let until = state.snoozedUntil, until > now { return true }
return false
}
}
func dismiss(id: String, type: WinnerType, until: Int? = nil) {
queue.sync {
let key = Self.keyString(id: id, type: type)
var state = states[key] ?? CardState()
state.dismissedUntil = until ?? Int.max
states[key] = state
save()
}
}
func snooze(id: String, type: WinnerType, until: Int) {
queue.sync {
let key = Self.keyString(id: id, type: type)
var state = states[key] ?? CardState()
state.snoozedUntil = until
states[key] = state
save()
}
}
func clearSuppression(id: String, type: WinnerType) {
queue.sync {
let key = Self.keyString(id: id, type: type)
var state = states[key] ?? CardState()
state.dismissedUntil = nil
state.snoozedUntil = nil
states[key] = state
save()
}
}
private func markShown(feed: FeedEnvelope, now: Int) {
for card in feed.feed {
let key = Self.keyString(id: card.id, type: card.type)
var state = states[key] ?? CardState()
state.lastShownAt = now
states[key] = state
}
}
private func normalizedFeed(_ feed: FeedEnvelope, now: Int, applyingSuppression: Bool = true) -> FeedEnvelope {
let normalizedCards = feed.feed.compactMap { card -> FeedCard? in
let ttl = max(1, card.ttlSec)
if feed.generatedAt + ttl <= now { return nil }
if applyingSuppression, isSuppressedUnlocked(id: card.id, type: card.type, now: now) { return nil }
return FeedCard(
id: card.id,
type: card.type,
title: card.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: card.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: min(max(card.priority, 0.0), 1.0),
ttlSec: ttl,
condition: card.condition,
bucket: card.bucket,
actions: card.actions
)
}
let winnerId = normalizedCards.first(where: { $0.id == feed.meta.winnerId })?.id ?? normalizedCards.first?.id ?? "quiet-000"
let normalized = FeedEnvelope(
schema: 1,
generatedAt: max(1, feed.generatedAt),
feed: normalizedCards,
meta: FeedMeta(winnerId: winnerId, unreadCount: normalizedCards.count)
)
return normalized
}
private func isSuppressedUnlocked(id: String, type: WinnerType, now: Int) -> Bool {
let key = Self.keyString(id: id, type: type)
guard let state = states[key] else { return false }
if let until = state.dismissedUntil, until > now { return true }
if let until = state.snoozedUntil, until > now { return true }
return false
}
private func save() {
let persisted = Persisted(feed: cachedFeed, states: states)
Self.save(persisted, to: fileURL)
}
private static func keyString(id: String, type: WinnerType) -> String {
"\(type.rawValue)|\(id)"
}
private static func defaultFileURL(filename: String) -> URL {
let fm = FileManager.default
let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory
let bundle = Bundle.main.bundleIdentifier ?? "iris"
return base
.appendingPathComponent(bundle, isDirectory: true)
.appendingPathComponent(filename, isDirectory: false)
}
private static func load(from url: URL) -> Persisted? {
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(Persisted.self, from: data)
}
private static func save(_ persisted: Persisted, to url: URL) {
do {
let fm = FileManager.default
try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
let data = try encoder.encode(persisted)
try data.write(to: url, options: [.atomic])
} catch {
// Best-effort persistence.
}
}
}