Initial lookbook implementation

Pinterest-style visual bookmarking app with:
- URL metadata extraction (OG/Twitter meta, oEmbed fallback)
- Image caching in Postgres with 480px thumbnails
- Multi-tag filtering with Ctrl/Cmd for OR mode
- Fuzzy tag suggestions and inline tag editing
- Browser console auth() with first-use password setup
- Brutalist UI with Commit Mono font and Pico CSS
- Light/dark mode via browser preference
This commit is contained in:
soup 2026-01-16 21:14:23 -05:00
commit fc625fb9cf
Signed by: soup
SSH key fingerprint: SHA256:GYxje8eQkJ6HZKzVWDdyOUF1TyDiprruGhE0Ym8qYDY
486 changed files with 195373 additions and 0 deletions

24
vendor/github.com/mfridman/interpolate/LICENSE.txt generated vendored Normal file
View file

@ -0,0 +1,24 @@
MIT License
Copyright (c) 2014-2017 Buildkite Pty Ltd
Copyright (c) 2023 Michael Fridman
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

85
vendor/github.com/mfridman/interpolate/README.md generated vendored Normal file
View file

@ -0,0 +1,85 @@
# Interpolate
[![Build Status](https://github.com/mfridman/interpolate/actions/workflows/ci.yaml/badge.svg)](https://github.com/mfridman/interpolate/actions/workflows/ci.yaml)
[![Go Reference](https://pkg.go.dev/badge/github.com/mfridman/interpolate.svg)](https://pkg.go.dev/github.com/mfridman/interpolate)
[![Go Report Card](https://goreportcard.com/badge/github.com/mfridman/interpolate)](https://goreportcard.com/report/github.com/mfridman/interpolate)
A Go library for parameter expansion (like `${NAME}` or `$NAME`) in strings from environment
variables. An implementation of [POSIX Parameter
Expansion](http://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_02),
plus some other basic operations that you'd expect in a shell scripting environment [like
bash](https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html).
## Installation
```
go get github.com/mfridman/interpolate@latest
```
## Usage
```go
package main
import (
"github.com/mfridman/interpolate"
"fmt"
)
func main() {
env := interpolate.NewSliceEnv([]string{
"NAME=James",
})
output, _ := interpolate.Interpolate(env, "Hello... ${NAME} welcome to the ${ANOTHER_VAR:-🏖}")
fmt.Println(output)
// Output: Hello... James welcome to the 🏖
}
```
## Supported Expansions
- `${parameter}` or `$parameter`
- **Use value.** If parameter is set, then it shall be substituted; otherwise, it will be blank
- `${parameter:-[word]}`
- **Use default values.** If parameter is unset or null, the expansion of word (or an empty string
if word is omitted) shall be substituted; otherwise, the value of parameter shall be
substituted.
- `${parameter-[word]}`
- **Use default values when not set.** If parameter is unset, the expansion of word (or an empty
string if word is omitted) shall be substituted; otherwise, the value of parameter shall be
substituted.
- `${parameter:[offset]}`
- **Use the substring of parameter after offset.** A negative offset must be separated from the
colon with a space, and will select from the end of the string. If the value is out of bounds,
an empty string will be substituted.
- `${parameter:[offset]:[length]}`
- **Use the substring of parameter after offset of given length.** A negative offset must be
separated from the colon with a space, and will select from the end of the string. If the offset
is out of bounds, an empty string will be substituted. If the length is greater than the length
then the entire string will be returned.
- `${parameter:?[word]}`
- **Indicate Error if Null or Unset.** If parameter is unset or null, the expansion of word (or a
message indicating it is unset if word is omitted) shall be returned as an error.
## Prior work
This repository is a fork of [buildkite/interpolate](https://github.com/buildkite/interpolate). I'd
like to thank the authors of that library for their work. I've forked it to make some changes that I
needed for my own use cases, and to make it easier to maintain. I've also added some tests and
documentation.
## License
Licensed under MIT license, in `LICENSE`.

53
vendor/github.com/mfridman/interpolate/env.go generated vendored Normal file
View file

@ -0,0 +1,53 @@
package interpolate
import (
"runtime"
"strings"
)
// Env is an interface for getting environment variables by name and returning a boolean indicating
// whether the variable was found.
type Env interface {
Get(key string) (string, bool)
}
// NewSliceEnv creates an Env from a slice of environment variables in the form "key=value".
//
// This can be used with [os.Environ] to create an Env.
func NewSliceEnv(env []string) Env {
envMap := mapEnv{}
for _, l := range env {
parts := strings.SplitN(l, "=", 2)
if len(parts) == 2 {
envMap[normalizeKeyName(parts[0])] = parts[1]
}
}
return envMap
}
// NewMapEnv creates an Env from a map of environment variables.
func NewMapEnv(env map[string]string) Env {
envMap := mapEnv{}
for k, v := range env {
envMap[normalizeKeyName(k)] = v
}
return envMap
}
type mapEnv map[string]string
func (m mapEnv) Get(key string) (string, bool) {
if m == nil {
return "", false
}
val, ok := m[normalizeKeyName(key)]
return val, ok
}
// Windows isn't case sensitive for env
func normalizeKeyName(key string) string {
if runtime.GOOS == "windows" {
return strings.ToUpper(key)
}
return key
}

213
vendor/github.com/mfridman/interpolate/interpolate.go generated vendored Normal file
View file

@ -0,0 +1,213 @@
package interpolate
import (
"bytes"
"fmt"
)
// Interpolate takes a set of environment and interpolates it into the provided string using shell
// script expansions
func Interpolate(env Env, str string) (string, error) {
if env == nil {
env = NewSliceEnv(nil)
}
expr, err := NewParser(str).Parse()
if err != nil {
return "", err
}
return expr.Expand(env)
}
// Indentifiers parses the identifiers from any expansions in the provided string
func Identifiers(str string) ([]string, error) {
expr, err := NewParser(str).Parse()
if err != nil {
return nil, err
}
return expr.Identifiers(), nil
}
// An expansion is something that takes in ENV and returns a string or an error
type Expansion interface {
Expand(env Env) (string, error)
Identifiers() []string
}
// VariableExpansion represents either $VAR or ${VAR}, our simplest expansion
type VariableExpansion struct {
Identifier string
}
func (e VariableExpansion) Identifiers() []string {
return []string{e.Identifier}
}
func (e VariableExpansion) Expand(env Env) (string, error) {
val, _ := env.Get(e.Identifier)
return val, nil
}
// EmptyValueExpansion returns either the value of an env, or a default value if it's unset or null
type EmptyValueExpansion struct {
Identifier string
Content Expression
}
func (e EmptyValueExpansion) Identifiers() []string {
return append([]string{e.Identifier}, e.Content.Identifiers()...)
}
func (e EmptyValueExpansion) Expand(env Env) (string, error) {
val, _ := env.Get(e.Identifier)
if val == "" {
return e.Content.Expand(env)
}
return val, nil
}
// UnsetValueExpansion returns either the value of an env, or a default value if it's unset
type UnsetValueExpansion struct {
Identifier string
Content Expression
}
func (e UnsetValueExpansion) Identifiers() []string {
return []string{e.Identifier}
}
func (e UnsetValueExpansion) Expand(env Env) (string, error) {
val, ok := env.Get(e.Identifier)
if !ok {
return e.Content.Expand(env)
}
return val, nil
}
// SubstringExpansion returns a substring (or slice) of the env
type SubstringExpansion struct {
Identifier string
Offset int
Length int
HasLength bool
}
func (e SubstringExpansion) Identifiers() []string {
return []string{e.Identifier}
}
func (e SubstringExpansion) Expand(env Env) (string, error) {
val, _ := env.Get(e.Identifier)
from := e.Offset
// Negative offsets = from end
if from < 0 {
from += len(val)
}
// Still negative = too far from end? Truncate to start.
if from < 0 {
from = 0
}
// Beyond end? Truncate to end.
if from > len(val) {
from = len(val)
}
if !e.HasLength {
return val[from:], nil
}
to := e.Length
if to >= 0 {
// Positive length = from offset
to += from
} else {
// Negative length = from end
to += len(val)
// Too far? Truncate to offset.
if to < from {
to = from
}
}
// Beyond end? Truncate to end.
if to > len(val) {
to = len(val)
}
return val[from:to], nil
}
// RequiredExpansion returns an env value, or an error if it is unset
type RequiredExpansion struct {
Identifier string
Message Expression
}
func (e RequiredExpansion) Identifiers() []string {
return []string{e.Identifier}
}
func (e RequiredExpansion) Expand(env Env) (string, error) {
val, ok := env.Get(e.Identifier)
if !ok {
msg, err := e.Message.Expand(env)
if err != nil {
return "", err
}
if msg == "" {
msg = "not set"
}
return "", fmt.Errorf("$%s: %s", e.Identifier, msg)
}
return val, nil
}
// Expression is a collection of either Text or Expansions
type Expression []ExpressionItem
func (e Expression) Identifiers() []string {
identifiers := []string{}
for _, item := range e {
if item.Expansion != nil {
identifiers = append(identifiers, item.Expansion.Identifiers()...)
}
}
return identifiers
}
func (e Expression) Expand(env Env) (string, error) {
buf := &bytes.Buffer{}
for _, item := range e {
if item.Expansion != nil {
result, err := item.Expansion.Expand(env)
if err != nil {
return "", err
}
_, _ = buf.WriteString(result)
} else {
_, _ = buf.WriteString(item.Text)
}
}
return buf.String(), nil
}
// ExpressionItem models either an Expansion or Text. Either/Or, never both.
type ExpressionItem struct {
Text string
// -- or --
Expansion Expansion
}
func (i ExpressionItem) String() string {
if i.Expansion != nil {
return fmt.Sprintf("%#v", i.Expansion)
}
return fmt.Sprintf("%q", i.Text)
}

281
vendor/github.com/mfridman/interpolate/parser.go generated vendored Normal file
View file

@ -0,0 +1,281 @@
package interpolate
import (
"fmt"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
// This is a recursive descent parser for our grammar. Because it can contain nested expressions
// like ${LLAMAS:-${ROCK:-true}} we can't use regular expressions. The simplest possible alternative
// is a recursive parser like this. It parses a chunk and then calls a function to parse that
// further and so on and so forth. It results in a tree of objects that represent the things we've
// parsed (an AST). This means that the logic for how expansions work lives in those objects, and
// the logic for how we go from plain text to parsed objects lives here.
//
// To keep things simple, we do our "lexing" or "scanning" just as a few functions at the end of the
// file rather than as a dedicated lexer that emits tokens. This matches the simplicity of the
// format we are parsing relatively well
//
// Below is an EBNF grammar for the language. The parser was built by basically turning this into
// functions and structs named the same reading the string bite by bite (peekRune and nextRune)
/*
EscapedBackslash = "\\" EscapedDollar = ( "\$" | "$$") Identifier = letter { letters |
digit | "_" } Expansion = "$" ( Identifier | Brace ) Brace = "{" Identifier [
Identifier BraceOperation ] "}" Text = { EscapedBackslash | EscapedDollar | all characters except
"$" } Expression = { Text | Expansion } EmptyValue = ":-" { Expression } UnsetValue =
"-" { Expression } Substring = ":" number [ ":" number ] Required = "?" { Expression }
Operation = EmptyValue | UnsetValue | Substring | Required
*/
const (
eof = -1
)
// Parser takes a string and parses out a tree of structs that represent text and Expansions
type Parser struct {
input string // the string we are scanning
pos int // the current position
}
// NewParser returns a new instance of a Parser
func NewParser(str string) *Parser {
return &Parser{
input: str,
pos: 0,
}
}
// Parse expansions out of the internal text and return them as a tree of Expressions
func (p *Parser) Parse() (Expression, error) {
return p.parseExpression()
}
func (p *Parser) parseExpression(stop ...rune) (Expression, error) {
var expr Expression
var stopStr = string(stop)
for {
c := p.peekRune()
if c == eof || strings.ContainsRune(stopStr, c) {
break
}
// check for our escaped characters first, as we assume nothing subsequently is escaped
if strings.HasPrefix(p.input[p.pos:], `\\`) {
p.pos += 2
expr = append(expr, ExpressionItem{Text: `\\`})
continue
} else if strings.HasPrefix(p.input[p.pos:], `\$`) || strings.HasPrefix(p.input[p.pos:], `$$`) {
p.pos += 2
expr = append(expr, ExpressionItem{Text: `$`})
continue
}
// Ignore bash shell expansions
if strings.HasPrefix(p.input[p.pos:], `$(`) {
p.pos += 2
expr = append(expr, ExpressionItem{Text: `$(`})
continue
}
// If we run into a dollar sign and it's not the last char, it's an expansion
if c == '$' && p.pos < (len(p.input)-1) {
expansion, err := p.parseExpansion()
if err != nil {
return nil, err
}
expr = append(expr, ExpressionItem{Expansion: expansion})
continue
}
// nibble a character, otherwise if it's a \ or a $ we can loop
c = p.nextRune()
// Scan as much as we can into text
text := p.scanUntil(func(r rune) bool {
return (r == '$' || r == '\\' || strings.ContainsRune(stopStr, r))
})
expr = append(expr, ExpressionItem{Text: string(c) + text})
}
return expr, nil
}
func (p *Parser) parseExpansion() (Expansion, error) {
if c := p.nextRune(); c != '$' {
return nil, fmt.Errorf("Expected expansion to start with $, got %c", c)
}
// if we have an open brace, this is a brace expansion
if c := p.peekRune(); c == '{' {
return p.parseBraceExpansion()
}
identifier, err := p.scanIdentifier()
if err != nil {
return nil, err
}
return VariableExpansion{Identifier: identifier}, nil
}
func (p *Parser) parseBraceExpansion() (Expansion, error) {
if c := p.nextRune(); c != '{' {
return nil, fmt.Errorf("Expected brace expansion to start with {, got %c", c)
}
identifier, err := p.scanIdentifier()
if err != nil {
return nil, err
}
if c := p.peekRune(); c == '}' {
_ = p.nextRune()
return VariableExpansion{Identifier: identifier}, nil
}
var operator string
var exp Expansion
// Parse an operator, some trickery is needed to handle : vs :-
if op1 := p.nextRune(); op1 == ':' {
if op2 := p.peekRune(); op2 == '-' {
_ = p.nextRune()
operator = ":-"
} else {
operator = ":"
}
} else if op1 == '?' || op1 == '-' {
operator = string(op1)
} else {
return nil, fmt.Errorf("Expected an operator, got %c", op1)
}
switch operator {
case `:-`:
exp, err = p.parseEmptyValueExpansion(identifier)
if err != nil {
return nil, err
}
case `-`:
exp, err = p.parseUnsetValueExpansion(identifier)
if err != nil {
return nil, err
}
case `:`:
exp, err = p.parseSubstringExpansion(identifier)
if err != nil {
return nil, err
}
case `?`:
exp, err = p.parseRequiredExpansion(identifier)
if err != nil {
return nil, err
}
}
if c := p.nextRune(); c != '}' {
return nil, fmt.Errorf("Expected brace expansion to end with }, got %c", c)
}
return exp, nil
}
func (p *Parser) parseEmptyValueExpansion(identifier string) (Expansion, error) {
// parse an expression (text and expansions) up until the end of the brace
expr, err := p.parseExpression('}')
if err != nil {
return nil, err
}
return EmptyValueExpansion{Identifier: identifier, Content: expr}, nil
}
func (p *Parser) parseUnsetValueExpansion(identifier string) (Expansion, error) {
expr, err := p.parseExpression('}')
if err != nil {
return nil, err
}
return UnsetValueExpansion{Identifier: identifier, Content: expr}, nil
}
func (p *Parser) parseSubstringExpansion(identifier string) (Expansion, error) {
offset := p.scanUntil(func(r rune) bool {
return r == ':' || r == '}'
})
offsetInt, err := strconv.Atoi(strings.TrimSpace(offset))
if err != nil {
return nil, fmt.Errorf("Unable to parse offset: %v", err)
}
if c := p.peekRune(); c == '}' {
return SubstringExpansion{Identifier: identifier, Offset: offsetInt}, nil
}
_ = p.nextRune()
length := p.scanUntil(func(r rune) bool {
return r == '}'
})
lengthInt, err := strconv.Atoi(strings.TrimSpace(length))
if err != nil {
return nil, fmt.Errorf("Unable to parse length: %v", err)
}
return SubstringExpansion{Identifier: identifier, Offset: offsetInt, Length: lengthInt, HasLength: true}, nil
}
func (p *Parser) parseRequiredExpansion(identifier string) (Expansion, error) {
expr, err := p.parseExpression('}')
if err != nil {
return nil, err
}
return RequiredExpansion{Identifier: identifier, Message: expr}, nil
}
func (p *Parser) scanUntil(f func(rune) bool) string {
start := p.pos
for int(p.pos) < len(p.input) {
c, size := utf8.DecodeRuneInString(p.input[p.pos:])
if c == utf8.RuneError || f(c) {
break
}
p.pos += size
}
return p.input[start:p.pos]
}
func (p *Parser) scanIdentifier() (string, error) {
if c := p.peekRune(); !unicode.IsLetter(c) {
return "", fmt.Errorf("Expected identifier to start with a letter, got %c", c)
}
var notIdentifierChar = func(r rune) bool {
return (!unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '_')
}
return p.scanUntil(notIdentifierChar), nil
}
func (p *Parser) nextRune() rune {
if int(p.pos) >= len(p.input) {
return eof
}
c, size := utf8.DecodeRuneInString(p.input[p.pos:])
p.pos += size
return c
}
func (p *Parser) peekRune() rune {
if int(p.pos) >= len(p.input) {
return eof
}
c, _ := utf8.DecodeRuneInString(p.input[p.pos:])
return c
}