forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

Changed files
+2640 -1495
.tangled
workflows
api
tangled
appview
consts
docs
spindle
knotserver
lexicons
repo
nix
types
+21 -2
appview/pages/templates/labels/fragments/label.html
···
{{ define "labels/fragments/label" }}
{{ $d := .def }}
{{ $v := .val }}
+
{{ $withPrefix := .withPrefix }}
<span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
-
{{ $d.Name }}{{ if not $d.ValueType.IsNull }}/{{ template "labelVal" (dict "def" $d "val" $v) }}{{ end }}
+
+
{{ $lhs := printf "%s" $d.Name }}
+
{{ $rhs := "" }}
+
+
{{ if not $d.ValueType.IsNull }}
+
{{ if $d.ValueType.IsDidFormat }}
+
{{ $v = resolve $v }}
+
{{ end }}
+
+
{{ if not $withPrefix }}
+
{{ $lhs = "" }}
+
{{ else }}
+
{{ $lhs = printf "%s/" $d.Name }}
+
{{ end }}
+
+
{{ $rhs = printf "%s" $v }}
+
{{ end }}
+
+
{{ printf "%s%s" $lhs $rhs }}
</span>
{{ end }}
···
{{ $v := .val }}
{{ if $d.ValueType.IsDidFormat }}
-
{{ resolve $v }}
+
{{ resolve $v }}
{{ else }}
{{ $v }}
{{ end }}
-127
appview/pages/templates/repo/fragments/addLabelModal.html
···
-
{{ define "repo/fragments/addLabelModal" }}
-
{{ $root := .root }}
-
{{ $subject := .subject }}
-
{{ $state := .state }}
-
{{ with $root }}
-
<form
-
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
-
hx-on::after-request="this.reset()"
-
hx-indicator="#spinner"
-
hx-swap="none"
-
class="flex flex-col gap-4"
-
>
-
<p class="text-gray-500 dark:text-gray-400">Add, remove or update labels.</p>
-
-
<input class="hidden" name="repo" value="{{ .RepoInfo.RepoAt.String }}">
-
<input class="hidden" name="subject" value="{{ $subject }}">
-
-
<div class="flex flex-col gap-2">
-
{{ $id := 0 }}
-
{{ range $k, $valset := $state.Inner }}
-
{{ $d := index $root.LabelDefs $k }}
-
{{ range $v, $s := $valset }}
-
{{ template "labelCheckbox" (dict "def" $d "key" $k "val" $v "id" $id "isChecked" true) }}
-
{{ $id = add $id 1 }}
-
{{ end }}
-
{{ end }}
-
-
{{ range $k, $d := $root.LabelDefs }}
-
{{ if not ($state.ContainsLabel $k) }}
-
{{ template "labelCheckbox" (dict "def" $d "key" $k "val" "" "id" $id "isChecked" false) }}
-
{{ $id = add $id 1 }}
-
{{ end }}
-
{{ else }}
-
<span>
-
No labels defined yet. You can define custom labels in <a class="underline" href="/{{ .RepoInfo.FullName }}/settings">settings</a>.
-
</span>
-
{{ end }}
-
</div>
-
-
<div class="flex gap-2 pt-2">
-
<button
-
type="button"
-
popovertarget="add-label-modal"
-
popovertargetaction="hide"
-
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
-
>
-
{{ i "x" "size-4" }} cancel
-
</button>
-
<button type="submit" class="btn w-1/2 flex items-center">
-
<span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span>
-
<span id="spinner" class="group">
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
</div>
-
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
-
</form>
-
{{ end }}
-
{{ end }}
-
-
{{ define "labelCheckbox" }}
-
{{ $key := .key }}
-
{{ $val := .val }}
-
{{ $def := .def }}
-
{{ $id := .id }}
-
{{ $isChecked := .isChecked }}
-
<div class="grid grid-cols-[auto_1fr_50%] gap-2 items-center cursor-pointer">
-
<input type="checkbox" id="op-{{$id}}" name="op-{{$id}}" value="add" {{if $isChecked}}checked{{end}} class="peer">
-
<label for="op-{{$id}}" class="flex items-center gap-2 text-base">{{ template "labels/fragments/labelDef" $def }}</label>
-
<div class="w-full hidden peer-checked:block">{{ template "valueTypeInput" (dict "valueType" $def.ValueType "value" $val "key" $key) }}</div>
-
<input type="hidden" name="operand-key" value="{{ $key }}">
-
</div>
-
{{ end }}
-
-
{{ define "valueTypeInput" }}
-
{{ $valueType := .valueType }}
-
{{ $value := .value }}
-
{{ $key := .key }}
-
-
{{ if $valueType.IsEnumType }}
-
{{ template "enumTypeInput" $ }}
-
{{ else if $valueType.IsBool }}
-
{{ template "boolTypeInput" $ }}
-
{{ else if $valueType.IsInt }}
-
{{ template "intTypeInput" $ }}
-
{{ else if $valueType.IsString }}
-
{{ template "stringTypeInput" $ }}
-
{{ else if $valueType.IsNull }}
-
{{ template "nullTypeInput" $ }}
-
{{ end }}
-
{{ end }}
-
-
{{ define "enumTypeInput" }}
-
{{ $valueType := .valueType }}
-
{{ $value := .value }}
-
<select name="operand-val" class="w-full p-1 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
-
{{ range $valueType.Enum }}
-
<option value="{{.}}" {{ if eq $value . }} selected {{ end }}>{{.}}</option>
-
{{ end }}
-
</select>
-
{{ end }}
-
-
{{ define "boolTypeInput" }}
-
{{ $value := .value }}
-
<select name="operand-val" class="w-full p-1 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
-
<option value="true" {{ if $value }} selected {{ end }}>true</option>
-
<option value="false" {{ if not $value }} selected {{ end }}>false</option>
-
</select>
-
{{ end }}
-
-
{{ define "intTypeInput" }}
-
{{ $value := .value }}
-
<input class="p-1 w-full" type="number" name="operand-val" value="{{$value}}" max="100">
-
{{ end }}
-
-
{{ define "stringTypeInput" }}
-
{{ $valueType := .valueType }}
-
{{ $value := .value }}
-
{{ if $valueType.IsDidFormat }}
-
{{ $value = resolve .value }}
-
{{ end }}
-
<input class="p-1 w-full" type="text" name="operand-val" value="{{$value}}">
-
{{ end }}
-
-
{{ define "nullTypeInput" }}
-
<input class="p-1" type="hidden" name="operand-val" value="null">
-
{{ end }}
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
···
+
{{ define "repo/fragments/editLabelPanel" }}
+
<form
+
id="edit-label-panel"
+
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
+
hx-indicator="#spinner"
+
hx-disabled-elt="#save-btn,#cancel-btn"
+
hx-swap="none"
+
class="flex flex-col gap-6"
+
>
+
<input type="hidden" name="repo" value="{{ .RepoInfo.RepoAt }}">
+
<input type="hidden" name="subject" value="{{ .Subject }}">
+
{{ template "editBasicLabels" . }}
+
{{ template "editKvLabels" . }}
+
{{ template "editLabelPanelActions" . }}
+
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
+
</form>
+
{{ end }}
+
+
{{ define "editBasicLabels" }}
+
{{ $defs := .Defs }}
+
{{ $subject := .Subject }}
+
{{ $state := .State }}
+
{{ $labelStyle := "flex items-center gap-2 rounded py-1 px-2 border border-gray-200 dark:border-gray-700 text-sm bg-white dark:bg-gray-800 text-black dark:text-white" }}
+
<div>
+
{{ template "repo/fragments/labelSectionHeaderText" "Labels" }}
+
+
<div class="flex gap-1 items-center flex-wrap">
+
{{ range $k, $d := $defs }}
+
{{ $isChecked := $state.ContainsLabel $k }}
+
{{ if $d.ValueType.IsNull }}
+
{{ $fieldName := $d.AtUri }}
+
<label class="{{$labelStyle}}">
+
<input type="checkbox" id="{{ $fieldName }}" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}>
+
{{ template "labels/fragments/labelDef" $d }}
+
</label>
+
{{ end }}
+
{{ else }}
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">
+
No labels defined yet. You can choose default labels or define custom
+
labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>.
+
</p>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "editKvLabels" }}
+
{{ $defs := .Defs }}
+
{{ $subject := .Subject }}
+
{{ $state := .State }}
+
{{ $labelStyle := "font-normal normal-case flex items-center gap-2 p-0" }}
+
+
{{ range $k, $d := $defs }}
+
{{ if (not $d.ValueType.IsNull) }}
+
{{ $fieldName := $d.AtUri }}
+
{{ $valset := $state.GetValSet $k }}
+
<div id="label-{{$d.Id}}" class="flex flex-col gap-1">
+
{{ template "repo/fragments/labelSectionHeaderText" $d.Name }}
+
{{ if (and $d.Multiple $d.ValueType.IsEnum) }}
+
<!-- checkbox -->
+
{{ range $variant := $d.ValueType.Enum }}
+
<label class="{{$labelStyle}}">
+
<input type="checkbox" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
+
{{ $variant }}
+
</label>
+
{{ end }}
+
{{ else if $d.Multiple }}
+
<!-- dynamically growing input fields -->
+
{{ range $v, $s := $valset }}
+
{{ template "multipleInputField" (dict "def" $d "value" $v "key" $k) }}
+
{{ else }}
+
{{ template "multipleInputField" (dict "def" $d "value" "" "key" $k) }}
+
{{ end }}
+
{{ template "addFieldButton" $d }}
+
{{ else if $d.ValueType.IsEnum }}
+
<!-- radio buttons -->
+
{{ $isUsed := $state.ContainsLabel $k }}
+
{{ range $variant := $d.ValueType.Enum }}
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
+
{{ $variant }}
+
</label>
+
{{ end }}
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="" {{ if not $isUsed }}checked{{ end }}>
+
None
+
</label>
+
{{ else }}
+
<!-- single input field based on value type -->
+
{{ range $v, $s := $valset }}
+
{{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }}
+
{{ else }}
+
{{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }}
+
{{ end }}
+
{{ end }}
+
</div>
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "multipleInputField" }}
+
<div class="flex gap-1 items-stretch">
+
{{ template "valueTypeInput" . }}
+
{{ template "removeFieldButton" }}
+
</div>
+
{{ end }}
+
+
{{ define "addFieldButton" }}
+
<div style="display:none" id="tpl-{{ .Id }}">
+
{{ template "multipleInputField" (dict "def" . "value" "" "key" .AtUri.String) }}
+
</div>
+
<button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ .Id }}').innerHTML)" class="w-full btn flex items-center gap-2">
+
{{ i "plus" "size-4" }} add
+
</button>
+
{{ end }}
+
+
{{ define "removeFieldButton" }}
+
<button type="button" onClick="this.parentElement.remove()" class="btn flex items-center gap-2 text-red-400 dark:text-red-500">
+
{{ i "trash-2" "size-4" }}
+
</button>
+
{{ end }}
+
+
{{ define "valueTypeInput" }}
+
{{ $def := .def }}
+
{{ $valueType := $def.ValueType }}
+
{{ $value := .value }}
+
{{ $key := .key }}
+
+
{{ if $valueType.IsBool }}
+
{{ template "boolTypeInput" $ }}
+
{{ else if $valueType.IsInt }}
+
{{ template "intTypeInput" $ }}
+
{{ else if $valueType.IsString }}
+
{{ template "stringTypeInput" $ }}
+
{{ else if $valueType.IsNull }}
+
{{ template "nullTypeInput" $ }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "boolTypeInput" }}
+
{{ $def := .def }}
+
{{ $fieldName := $def.AtUri }}
+
{{ $value := .value }}
+
{{ $labelStyle = "font-normal normal-case flex items-center gap-2" }}
+
<div class="flex flex-col gap-1">
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
+
None
+
</label>
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
+
None
+
</label>
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
+
None
+
</label>
+
</div>
+
{{ end }}
+
+
{{ define "intTypeInput" }}
+
{{ $def := .def }}
+
{{ $fieldName := $def.AtUri }}
+
{{ $value := .value }}
+
<input class="p-1 w-full" type="number" name="{{$fieldName}}" value="{{$value}}">
+
{{ end }}
+
+
{{ define "stringTypeInput" }}
+
{{ $def := .def }}
+
{{ $fieldName := $def.AtUri }}
+
{{ $valueType := $def.ValueType }}
+
{{ $value := .value }}
+
{{ if $valueType.IsDidFormat }}
+
{{ $value = trimPrefix (resolve .value) "@" }}
+
{{ end }}
+
<input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}">
+
{{ end }}
+
+
{{ define "nullTypeInput" }}
+
{{ $def := .def }}
+
{{ $fieldName := $def.AtUri }}
+
<input class="p-1" type="hidden" name="{{$fieldName}}" value="null">
+
{{ end }}
+
+
{{ define "editLabelPanelActions" }}
+
<div class="flex gap-2 pt-2">
+
<button
+
id="cancel-btn"
+
type="button"
+
hx-get="/{{ .RepoInfo.FullName }}/label"
+
hx-vals='{"subject": "{{.Subject}}"}'
+
hx-swap="outerHTML"
+
hx-target="#edit-label-panel"
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 group">
+
{{ i "x" "size-4" }} cancel
+
</button>
+
+
<button
+
id="save-btn"
+
type="submit"
+
class="btn w-1/2 flex items-center">
+
<span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span>
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</div>
+
{{ end }}
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
···
+
{{ define "repo/fragments/labelSectionHeader" }}
+
+
<div class="flex justify-between items-center gap-2">
+
{{ template "repo/fragments/labelSectionHeaderText" .Name }}
+
{{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/label/edit"
+
hx-vals='{"subject": "{{.Subject}}"}'
+
hx-swap="outerHTML"
+
hx-target="#label-panel">
+
{{ i "pencil" "size-3" }}
+
</a>
+
{{ end }}
+
</div>
+
{{ end }}
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
···
+
{{ define "repo/fragments/labelSectionHeaderText" }}
+
<span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">{{ . }}</span>
+
{{ end }}
+138 -86
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
···
{{ define "repo/settings/fragments/addLabelDefModal" }}
-
<form
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
-
hx-indicator="#spinner"
-
hx-swap="none"
-
hx-on::after-request="if(event.detail.successful) this.reset()"
-
class="flex flex-col gap-4"
-
>
-
<p class="text-gray-500 dark:text-gray-400">Labels can have a name and a value. Set the value type to "none" to create a simple label.</p>
+
<div class="grid grid-cols-2">
+
<input type="radio" name="tab" id="basic-tab" value="basic" class="hidden peer/basic" checked>
+
<input type="radio" name="tab" id="kv-tab" value="kv" class="hidden peer/kv">
-
<div class="w-full">
-
<label for="name">Name</label>
-
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
+
<!-- Labels as direct siblings -->
+
{{ $base := "py-2 text-sm font-normal normal-case block hover:no-underline text-center cursor-pointer bg-gray-100 dark:bg-gray-800 shadow-inner border border-gray-200 dark:border-gray-700" }}
+
<label for="basic-tab" class="{{$base}} peer-checked/basic:bg-white peer-checked/basic:dark:bg-gray-700 peer-checked/basic:shadow-sm rounded-l">
+
Basic Labels
+
</label>
+
<label for="kv-tab" class="{{$base}} peer-checked/kv:bg-white peer-checked/kv:dark:bg-gray-700 peer-checked/kv:shadow-sm rounded-r">
+
Key-value Labels
+
</label>
+
+
<!-- Basic Labels Content - direct sibling -->
+
<div class="mt-4 hidden peer-checked/basic:block col-span-full">
+
{{ template "basicLabelDef" . }}
</div>
-
<!-- Value Type -->
-
<div class="w-full">
-
<label for="valueType">Value Type</label>
-
<select id="value-type" name="valueType" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
-
<option value="null" selected>None</option>
-
<option value="string">String</option>
-
<option value="integer">Integer</option>
-
<option value="boolean">Boolean</option>
-
</select>
-
<details id="constrain-values" class="group hidden">
-
<summary class="list-none cursor-pointer flex items-center gap-2 py-2">
-
<span class="group-open:hidden inline text-gray-500 dark:text-gray-400">{{ i "square-plus" "w-4 h-4" }}</span>
-
<span class="hidden group-open:inline text-gray-500 dark:text-gray-400">{{ i "square-minus" "w-4 h-4" }}</span>
-
<span>Constrain values</span>
-
</summary>
-
<label for="enumValues">Permitted values</label>
-
<input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/>
-
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Enter comma-separated list of permitted values.</p>
-
-
<label for="valueFormat">String format</label>
-
<select id="valueFormat" name="valueFormat" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
-
<option value="any" selected>Any</option>
-
<option value="did">DID</option>
-
</select>
-
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Choose a string format.</p>
-
</details>
+
<!-- Key-value Labels Content - direct sibling -->
+
<div class="mt-4 hidden peer-checked/kv:block col-span-full">
+
{{ template "kvLabelDef" . }}
</div>
-
<!-- Scope -->
+
<div id="add-label-error" class="text-red-500 dark:text-red-400 col-span-full"></div>
+
</div>
+
{{ end }}
+
+
{{ define "basicLabelDef" }}
+
<form
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
hx-on::after-request="if(event.detail.successful) this.reset()"
+
class="flex flex-col space-y-4">
+
+
<p class="text-gray-500 dark:text-gray-400">These labels can have a name and a color.</p>
+
+
{{ template "nameInput" . }}
+
{{ template "scopeInput" . }}
+
{{ template "colorInput" . }}
+
+
<div class="flex gap-2 pt-2">
+
{{ template "cancelButton" . }}
+
{{ template "submitButton" . }}
+
</div>
+
</form>
+
{{ end }}
+
+
{{ define "kvLabelDef" }}
+
<form
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
hx-on::after-request="if(event.detail.successful) this.reset()"
+
class="flex flex-col space-y-4">
+
+
<p class="text-gray-500 dark:text-gray-400">
+
These labels are more detailed, they can have a key and an associated
+
value. You may define additional constraints on label values.
+
</p>
+
+
{{ template "nameInput" . }}
+
{{ template "valueInput" . }}
+
{{ template "multipleInput" . }}
+
{{ template "scopeInput" . }}
+
{{ template "colorInput" . }}
+
+
<div class="flex gap-2 pt-2">
+
{{ template "cancelButton" . }}
+
{{ template "submitButton" . }}
+
</div>
+
</form>
+
{{ end }}
+
+
{{ define "nameInput" }}
<div class="w-full">
-
<label for="scope">Scope</label>
-
<select id="scope" name="scope" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
-
<option value="sh.tangled.repo.issue">Issues</option>
-
<option value="sh.tangled.repo.pull">Pull Requests</option>
-
</select>
+
<label for="name">Name</label>
+
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
</div>
+
{{ end }}
-
<!-- Color -->
+
{{ define "colorInput" }}
<div class="w-full">
<label for="color">Color</label>
<div class="grid grid-cols-4 grid-rows-2 place-items-center">
···
{{ end }}
</div>
</div>
+
{{ end }}
-
<!-- Multiple -->
-
<div class="w-full flex flex-wrap gap-2">
-
<input type="checkbox" id="multiple" name="multiple" value="true" />
-
<span>
-
Allow multiple values
-
</span>
+
{{ define "scopeInput" }}
+
<div class="w-full">
+
<label>Scope</label>
+
<label class="font-normal normal-case flex items-center gap-2 p-0">
+
<input type="checkbox" id="issue-scope" name="scope" value="sh.tangled.repo.issue" checked />
+
Issues
+
</label>
+
<label class="font-normal normal-case flex items-center gap-2 p-0">
+
<input type="checkbox" id="pulls-scope" name="scope" value="sh.tangled.repo.pull" checked />
+
Pull Requests
+
</label>
+
</div>
+
{{ end }}
+
+
{{ define "valueInput" }}
+
<div class="w-full">
+
<label for="valueType">Value Type</label>
+
<select id="value-type" name="valueType" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
+
<option value="string">String</option>
+
<option value="integer">Integer</option>
+
</select>
</div>
-
<div class="flex gap-2 pt-2">
-
<button
-
type="button"
-
popovertarget="add-labeldef-modal"
-
popovertargetaction="hide"
-
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
-
>
-
{{ i "x" "size-4" }} cancel
-
</button>
-
<button type="submit" class="btn w-1/2 flex items-center">
-
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
-
<span id="spinner" class="group">
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
+
<div class="w-full">
+
<label for="enumValues">Permitted values</label>
+
<input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/>
+
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">
+
Enter comma-separated list of permitted values, or leave empty to allow any value.
+
</p>
</div>
-
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
-
</form>
-
-
<script>
-
document.getElementById('value-type').addEventListener('change', function() {
-
const constrainValues = document.getElementById('constrain-values');
-
const selectedValue = this.value;
-
-
if (selectedValue === 'string') {
-
constrainValues.classList.remove('hidden');
-
} else {
-
constrainValues.classList.add('hidden');
-
constrainValues.removeAttribute('open');
-
document.getElementById('enumValues').value = '';
-
}
-
});
-
-
function toggleDarkMode() {
-
document.documentElement.classList.toggle('dark');
-
}
-
</script>
+
+
<div class="w-full">
+
<label for="valueFormat">String format</label>
+
<select id="valueFormat" name="valueFormat" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
+
<option value="any" selected>Any</option>
+
<option value="did">DID</option>
+
</select>
+
</div>
+
{{ end }}
+
+
{{ define "multipleInput" }}
+
<div class="w-full flex flex-wrap gap-2">
+
<input type="checkbox" id="multiple" name="multiple" value="true" />
+
<span>Allow multiple values</span>
+
</div>
{{ end }}
+
{{ define "cancelButton" }}
+
<button
+
type="button"
+
popovertarget="add-labeldef-modal"
+
popovertargetaction="hide"
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+
>
+
{{ i "x" "size-4" }} cancel
+
</button>
+
{{ end }}
+
+
{{ define "submitButton" }}
+
<button type="submit" class="btn-create w-1/2 flex items-center">
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
{{ end }}
+
+
+4 -7
appview/pages/templates/repo/issues/issues.html
···
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
</span>
-
{{ if .Labels.Inner }}
-
<span class="before:content-['ยท']"></span>
-
{{ range $k, $valset := .Labels.Inner }}
-
{{ $d := index $.LabelDefs $k }}
-
{{ range $v, $s := $valset }}
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v) }}
-
{{ end }}
+
{{ $state := .Labels }}
+
{{ range $k, $d := $.LabelDefs }}
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
{{ end }}
{{ end }}
</div>
+25 -27
appview/pages/templates/repo/settings/fragments/labelListing.html
···
{{ define "repo/settings/fragments/labelListing" }}
{{ $root := index . 0 }}
{{ $label := index . 1 }}
-
<div id="label-{{$label.Id}}" class="flex items-center justify-between p-2 pl-4">
-
<div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
-
{{ template "labels/fragments/labelDef" $label }}
-
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
+
<div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
+
{{ template "labels/fragments/labelDef" $label }}
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
+
{{ if $label.ValueType.IsNull }}
+
basic
+
{{ else }}
{{ $label.ValueType.Type }} type
-
{{ if $label.ValueType.IsEnumType }}
-
<span class="before:content-['ยท'] before:select-none"></span>
-
{{ join $label.ValueType.Enum ", " }}
-
{{ end }}
-
{{ if $label.ValueType.IsDidFormat }}
-
<span class="before:content-['ยท'] before:select-none"></span>
-
DID format
-
{{ end }}
-
</div>
+
{{ end }}
+
+
{{ if $label.ValueType.IsEnum }}
+
<span class="before:content-['ยท'] before:select-none"></span>
+
{{ join $label.ValueType.Enum ", " }}
+
{{ end }}
+
+
{{ if $label.ValueType.IsDidFormat }}
+
<span class="before:content-['ยท'] before:select-none"></span>
+
DID format
+
{{ end }}
+
+
{{ if $label.Multiple }}
+
<span class="before:content-['ยท'] before:select-none"></span>
+
multiple
+
{{ end }}
+
+
<span class="before:content-['ยท'] before:select-none"></span>
+
{{ join $label.Scope ", " }}
</div>
-
{{ if $root.RepoInfo.Roles.IsOwner }}
-
<button
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
-
title="Delete label"
-
hx-delete="/{{ $root.RepoInfo.FullName }}/settings/label"
-
hx-swap="none"
-
hx-vals='{"label-id": "{{ $label.Id }}"}'
-
hx-confirm="Are you sure you want to delete the label `{{ $label.Name }}`?"
-
>
-
{{ i "trash-2" "w-5 h-5" }}
-
<span class="hidden md:inline">delete</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
</div>
{{ end }}
+9
consts/consts.go
···
+
package consts
+
+
const (
+
TangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli"
+
IcyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq"
+
+
DefaultSpindle = "spindle.tangled.sh"
+
DefaultKnot = "knot1.tangled.sh"
+
)
+5 -25
appview/db/artifact.go
···
"strings"
"time"
-
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/go-git/go-git/v5/plumbing"
"github.com/ipfs/go-cid"
-
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/models"
)
-
type Artifact struct {
-
Id uint64
-
Did string
-
Rkey string
-
-
RepoAt syntax.ATURI
-
Tag plumbing.Hash
-
CreatedAt time.Time
-
-
BlobCid cid.Cid
-
Name string
-
Size uint64
-
MimeType string
-
}
-
-
func (a *Artifact) ArtifactAt() syntax.ATURI {
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey))
-
}
-
-
func AddArtifact(e Execer, artifact Artifact) error {
+
func AddArtifact(e Execer, artifact models.Artifact) error {
_, err := e.Exec(
`insert or ignore into artifacts (
did,
···
return err
}
-
func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) {
-
var artifacts []Artifact
+
func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) {
+
var artifacts []models.Artifact
var conditions []string
var args []any
···
defer rows.Close()
for rows.Next() {
-
var artifact Artifact
+
var artifact models.Artifact
var createdAt string
var tag []byte
var blobCid string
+30
appview/models/artifact.go
···
+
package models
+
+
import (
+
"fmt"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/go-git/go-git/v5/plumbing"
+
"github.com/ipfs/go-cid"
+
"tangled.org/core/api/tangled"
+
)
+
+
type Artifact struct {
+
Id uint64
+
Did string
+
Rkey string
+
+
RepoAt syntax.ATURI
+
Tag plumbing.Hash
+
CreatedAt time.Time
+
+
BlobCid cid.Cid
+
Name string
+
Size uint64
+
MimeType string
+
}
+
+
func (a *Artifact) ArtifactAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey))
+
}
+21
appview/models/collaborator.go
···
+
package models
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type Collaborator struct {
+
// identifiers for the record
+
Id int64
+
Did syntax.DID
+
Rkey string
+
+
// content
+
SubjectDid syntax.DID
+
RepoAt syntax.ATURI
+
+
// meta
+
Created time.Time
+
}
+16
appview/models/email.go
···
+
package models
+
+
import (
+
"time"
+
)
+
+
type Email struct {
+
ID int64
+
Did string
+
Address string
+
Verified bool
+
Primary bool
+
VerificationCode string
+
LastSent *time.Time
+
CreatedAt time.Time
+
}
+26 -57
appview/db/follow.go
···
"log"
"strings"
"time"
-
)
-
type Follow struct {
-
UserDid string
-
SubjectDid string
-
FollowedAt time.Time
-
Rkey string
-
}
+
"tangled.org/core/appview/models"
+
)
-
func AddFollow(e Execer, follow *Follow) error {
+
func AddFollow(e Execer, follow *models.Follow) error {
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
return err
}
// Get a follow record
-
func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) {
+
func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) {
query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?`
row := e.QueryRow(query, userDid, subjectDid)
-
var follow Follow
+
var follow models.Follow
var followedAt string
err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey)
if err != nil {
···
return err
}
-
type FollowStats struct {
-
Followers int64
-
Following int64
-
}
-
-
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
+
func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) {
var followers, following int64
err := e.QueryRow(
`SELECT
···
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
FROM follows;`, did, did).Scan(&followers, &following)
if err != nil {
-
return FollowStats{}, err
+
return models.FollowStats{}, err
}
-
return FollowStats{
+
return models.FollowStats{
Followers: followers,
Following: following,
}, nil
}
-
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) {
+
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) {
if len(dids) == 0 {
return nil, nil
}
···
) g on f.did = g.did`,
placeholderStr, placeholderStr)
-
result := make(map[string]FollowStats)
+
result := make(map[string]models.FollowStats)
rows, err := e.Query(query, args...)
if err != nil {
···
if err := rows.Scan(&did, &followers, &following); err != nil {
return nil, err
}
-
result[did] = FollowStats{
+
result[did] = models.FollowStats{
Followers: followers,
Following: following,
}
···
for _, did := range dids {
if _, exists := result[did]; !exists {
-
result[did] = FollowStats{
+
result[did] = models.FollowStats{
Followers: 0,
Following: 0,
}
···
return result, nil
}
-
func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) {
-
var follows []Follow
+
func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) {
+
var follows []models.Follow
var conditions []string
var args []any
···
return nil, err
}
for rows.Next() {
-
var follow Follow
+
var follow models.Follow
var followedAt string
err := rows.Scan(
&follow.UserDid,
···
return follows, nil
}
-
func GetFollowers(e Execer, did string) ([]Follow, error) {
+
func GetFollowers(e Execer, did string) ([]models.Follow, error) {
return GetFollows(e, 0, FilterEq("subject_did", did))
}
-
func GetFollowing(e Execer, did string) ([]Follow, error) {
+
func GetFollowing(e Execer, did string) ([]models.Follow, error) {
return GetFollows(e, 0, FilterEq("user_did", did))
}
-
type FollowStatus int
-
-
const (
-
IsNotFollowing FollowStatus = iota
-
IsFollowing
-
IsSelf
-
)
-
-
func (s FollowStatus) String() string {
-
switch s {
-
case IsNotFollowing:
-
return "IsNotFollowing"
-
case IsFollowing:
-
return "IsFollowing"
-
case IsSelf:
-
return "IsSelf"
-
default:
-
return "IsNotFollowing"
-
}
-
}
-
-
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
+
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
if len(subjectDids) == 0 || userDid == "" {
-
return make(map[string]FollowStatus), nil
+
return make(map[string]models.FollowStatus), nil
}
-
result := make(map[string]FollowStatus)
+
result := make(map[string]models.FollowStatus)
for _, subjectDid := range subjectDids {
if userDid == subjectDid {
-
result[subjectDid] = IsSelf
+
result[subjectDid] = models.IsSelf
} else {
-
result[subjectDid] = IsNotFollowing
+
result[subjectDid] = models.IsNotFollowing
}
}
···
if err := rows.Scan(&subjectDid); err != nil {
return nil, err
}
-
result[subjectDid] = IsFollowing
+
result[subjectDid] = models.IsFollowing
}
return result, nil
}
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
+
func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus {
statuses, err := getFollowStatuses(e, userDid, []string{subjectDid})
if err != nil {
-
return IsNotFollowing
+
return models.IsNotFollowing
}
return statuses[subjectDid]
}
-
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
+
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
return getFollowStatuses(e, userDid, subjectDids)
}
+38
appview/models/follow.go
···
+
package models
+
+
import (
+
"time"
+
)
+
+
type Follow struct {
+
UserDid string
+
SubjectDid string
+
FollowedAt time.Time
+
Rkey string
+
}
+
+
type FollowStats struct {
+
Followers int64
+
Following int64
+
}
+
+
type FollowStatus int
+
+
const (
+
IsNotFollowing FollowStatus = iota
+
IsFollowing
+
IsSelf
+
)
+
+
func (s FollowStatus) String() string {
+
switch s {
+
case IsNotFollowing:
+
return "IsNotFollowing"
+
case IsFollowing:
+
return "IsFollowing"
+
case IsSelf:
+
return "IsSelf"
+
default:
+
return "IsNotFollowing"
+
}
+
}
+4 -3
appview/state/follow.go
···
lexutil "github.com/bluesky-social/indigo/lex/util"
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
"tangled.org/core/appview/pages"
"tangled.org/core/tid"
)
···
log.Println("created atproto record: ", resp.Uri)
-
follow := &db.Follow{
+
follow := &models.Follow{
UserDid: currentUser.Did,
SubjectDid: subjectIdent.DID.String(),
Rkey: rkey,
···
s.pages.FollowFragment(w, pages.FollowFragmentParams{
UserDid: subjectIdent.DID.String(),
-
FollowStatus: db.IsFollowing,
+
FollowStatus: models.IsFollowing,
})
return
···
s.pages.FollowFragment(w, pages.FollowFragmentParams{
UserDid: subjectIdent.DID.String(),
-
FollowStatus: db.IsNotFollowing,
+
FollowStatus: models.IsNotFollowing,
})
s.notifier.DeleteFollow(r.Context(), follow)
+2 -1
appview/knots/knots.go
···
"tangled.org/core/appview/config"
"tangled.org/core/appview/db"
"tangled.org/core/appview/middleware"
+
"tangled.org/core/appview/models"
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
"tangled.org/core/appview/serververify"
···
}
// organize repos by did
-
repoMap := make(map[string][]db.Repo)
+
repoMap := make(map[string][]models.Repo)
for _, r := range repos {
repoMap[r.Did] = append(repoMap[r.Did], r)
}
+4 -4
appview/state/git_http.go
···
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/go-chi/chi/v5"
-
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
)
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("resolvedId").(identity.Identity)
-
repo := r.Context().Value("repo").(*db.Repo)
+
repo := r.Context().Value("repo").(*models.Repo)
scheme := "https"
if s.config.Core.Dev {
···
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
return
}
-
repo := r.Context().Value("repo").(*db.Repo)
+
repo := r.Context().Value("repo").(*models.Repo)
scheme := "https"
if s.config.Core.Dev {
···
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
return
}
-
repo := r.Context().Value("repo").(*db.Repo)
+
repo := r.Context().Value("repo").(*models.Repo)
scheme := "https"
if s.config.Core.Dev {
+194
appview/models/issue.go
···
+
package models
+
+
import (
+
"fmt"
+
"sort"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/api/tangled"
+
)
+
+
type Issue struct {
+
Id int64
+
Did string
+
Rkey string
+
RepoAt syntax.ATURI
+
IssueId int
+
Created time.Time
+
Edited *time.Time
+
Deleted *time.Time
+
Title string
+
Body string
+
Open bool
+
+
// optionally, populate this when querying for reverse mappings
+
// like comment counts, parent repo etc.
+
Comments []IssueComment
+
Labels LabelState
+
Repo *Repo
+
}
+
+
func (i *Issue) AtUri() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
+
}
+
+
func (i *Issue) AsRecord() tangled.RepoIssue {
+
return tangled.RepoIssue{
+
Repo: i.RepoAt.String(),
+
Title: i.Title,
+
Body: &i.Body,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
}
+
}
+
+
func (i *Issue) State() string {
+
if i.Open {
+
return "open"
+
}
+
return "closed"
+
}
+
+
type CommentListItem struct {
+
Self *IssueComment
+
Replies []*IssueComment
+
}
+
+
func (i *Issue) CommentList() []CommentListItem {
+
// Create a map to quickly find comments by their aturi
+
toplevel := make(map[string]*CommentListItem)
+
var replies []*IssueComment
+
+
// collect top level comments into the map
+
for _, comment := range i.Comments {
+
if comment.IsTopLevel() {
+
toplevel[comment.AtUri().String()] = &CommentListItem{
+
Self: &comment,
+
}
+
} else {
+
replies = append(replies, &comment)
+
}
+
}
+
+
for _, r := range replies {
+
parentAt := *r.ReplyTo
+
if parent, exists := toplevel[parentAt]; exists {
+
parent.Replies = append(parent.Replies, r)
+
}
+
}
+
+
var listing []CommentListItem
+
for _, v := range toplevel {
+
listing = append(listing, *v)
+
}
+
+
// sort everything
+
sortFunc := func(a, b *IssueComment) bool {
+
return a.Created.Before(b.Created)
+
}
+
sort.Slice(listing, func(i, j int) bool {
+
return sortFunc(listing[i].Self, listing[j].Self)
+
})
+
for _, r := range listing {
+
sort.Slice(r.Replies, func(i, j int) bool {
+
return sortFunc(r.Replies[i], r.Replies[j])
+
})
+
}
+
+
return listing
+
}
+
+
func (i *Issue) Participants() []string {
+
participantSet := make(map[string]struct{})
+
participants := []string{}
+
+
addParticipant := func(did string) {
+
if _, exists := participantSet[did]; !exists {
+
participantSet[did] = struct{}{}
+
participants = append(participants, did)
+
}
+
}
+
+
addParticipant(i.Did)
+
+
for _, c := range i.Comments {
+
addParticipant(c.Did)
+
}
+
+
return participants
+
}
+
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
+
if err != nil {
+
created = time.Now()
+
}
+
+
body := ""
+
if record.Body != nil {
+
body = *record.Body
+
}
+
+
return Issue{
+
RepoAt: syntax.ATURI(record.Repo),
+
Did: did,
+
Rkey: rkey,
+
Created: created,
+
Title: record.Title,
+
Body: body,
+
Open: true, // new issues are open by default
+
}
+
}
+
+
type IssueComment struct {
+
Id int64
+
Did string
+
Rkey string
+
IssueAt string
+
ReplyTo *string
+
Body string
+
Created time.Time
+
Edited *time.Time
+
Deleted *time.Time
+
}
+
+
func (i *IssueComment) AtUri() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
+
}
+
+
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
+
return tangled.RepoIssueComment{
+
Body: i.Body,
+
Issue: i.IssueAt,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
ReplyTo: i.ReplyTo,
+
}
+
}
+
+
func (i *IssueComment) IsTopLevel() bool {
+
return i.ReplyTo == nil
+
}
+
+
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
+
if err != nil {
+
created = time.Now()
+
}
+
+
ownerDid := did
+
+
if _, err = syntax.ParseATURI(record.Issue); err != nil {
+
return nil, err
+
}
+
+
comment := IssueComment{
+
Did: ownerDid,
+
Rkey: rkey,
+
Body: record.Body,
+
IssueAt: record.Issue,
+
ReplyTo: record.ReplyTo,
+
Created: created,
+
}
+
+
return &comment, nil
+
}
+3 -2
appview/validator/issue.go
···
"strings"
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
)
-
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
+
func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error {
// if comments have parents, only ingest ones that are 1 level deep
if comment.ReplyTo != nil {
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
···
return nil
}
-
func (v *Validator) ValidateIssue(issue *db.Issue) error {
+
func (v *Validator) ValidateIssue(issue *models.Issue) error {
if issue.Title == "" {
return fmt.Errorf("issue title is empty")
}
+14
appview/models/language.go
···
+
package models
+
+
import (
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type RepoLanguage struct {
+
Id int64
+
RepoAt syntax.ATURI
+
Ref string
+
IsDefaultRef bool
+
Language string
+
Bytes int64
+
}
-173
appview/db/oauth.go
···
-
package db
-
-
type OAuthRequest struct {
-
ID uint
-
AuthserverIss string
-
Handle string
-
State string
-
Did string
-
PdsUrl string
-
PkceVerifier string
-
DpopAuthserverNonce string
-
DpopPrivateJwk string
-
}
-
-
func SaveOAuthRequest(e Execer, oauthRequest OAuthRequest) error {
-
_, err := e.Exec(`
-
insert into oauth_requests (
-
auth_server_iss,
-
state,
-
handle,
-
did,
-
pds_url,
-
pkce_verifier,
-
dpop_auth_server_nonce,
-
dpop_private_jwk
-
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
-
oauthRequest.AuthserverIss,
-
oauthRequest.State,
-
oauthRequest.Handle,
-
oauthRequest.Did,
-
oauthRequest.PdsUrl,
-
oauthRequest.PkceVerifier,
-
oauthRequest.DpopAuthserverNonce,
-
oauthRequest.DpopPrivateJwk,
-
)
-
return err
-
}
-
-
func GetOAuthRequestByState(e Execer, state string) (OAuthRequest, error) {
-
var req OAuthRequest
-
err := e.QueryRow(`
-
select
-
id,
-
auth_server_iss,
-
handle,
-
state,
-
did,
-
pds_url,
-
pkce_verifier,
-
dpop_auth_server_nonce,
-
dpop_private_jwk
-
from oauth_requests
-
where state = ?`, state).Scan(
-
&req.ID,
-
&req.AuthserverIss,
-
&req.Handle,
-
&req.State,
-
&req.Did,
-
&req.PdsUrl,
-
&req.PkceVerifier,
-
&req.DpopAuthserverNonce,
-
&req.DpopPrivateJwk,
-
)
-
return req, err
-
}
-
-
func DeleteOAuthRequestByState(e Execer, state string) error {
-
_, err := e.Exec(`
-
delete from oauth_requests
-
where state = ?`, state)
-
return err
-
}
-
-
type OAuthSession struct {
-
ID uint
-
Handle string
-
Did string
-
PdsUrl string
-
AccessJwt string
-
RefreshJwt string
-
AuthServerIss string
-
DpopPdsNonce string
-
DpopAuthserverNonce string
-
DpopPrivateJwk string
-
Expiry string
-
}
-
-
func SaveOAuthSession(e Execer, session OAuthSession) error {
-
_, err := e.Exec(`
-
insert into oauth_sessions (
-
did,
-
handle,
-
pds_url,
-
access_jwt,
-
refresh_jwt,
-
auth_server_iss,
-
dpop_auth_server_nonce,
-
dpop_private_jwk,
-
expiry
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
-
session.Did,
-
session.Handle,
-
session.PdsUrl,
-
session.AccessJwt,
-
session.RefreshJwt,
-
session.AuthServerIss,
-
session.DpopAuthserverNonce,
-
session.DpopPrivateJwk,
-
session.Expiry,
-
)
-
return err
-
}
-
-
func RefreshOAuthSession(e Execer, did string, accessJwt, refreshJwt, expiry string) error {
-
_, err := e.Exec(`
-
update oauth_sessions
-
set access_jwt = ?, refresh_jwt = ?, expiry = ?
-
where did = ?`,
-
accessJwt,
-
refreshJwt,
-
expiry,
-
did,
-
)
-
return err
-
}
-
-
func GetOAuthSessionByDid(e Execer, did string) (*OAuthSession, error) {
-
var session OAuthSession
-
err := e.QueryRow(`
-
select
-
id,
-
did,
-
handle,
-
pds_url,
-
access_jwt,
-
refresh_jwt,
-
auth_server_iss,
-
dpop_auth_server_nonce,
-
dpop_private_jwk,
-
expiry
-
from oauth_sessions
-
where did = ?`, did).Scan(
-
&session.ID,
-
&session.Did,
-
&session.Handle,
-
&session.PdsUrl,
-
&session.AccessJwt,
-
&session.RefreshJwt,
-
&session.AuthServerIss,
-
&session.DpopAuthserverNonce,
-
&session.DpopPrivateJwk,
-
&session.Expiry,
-
)
-
return &session, err
-
}
-
-
func DeleteOAuthSessionByDid(e Execer, did string) error {
-
_, err := e.Exec(`
-
delete from oauth_sessions
-
where did = ?`, did)
-
return err
-
}
-
-
func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error {
-
_, err := e.Exec(`
-
update oauth_sessions
-
set dpop_pds_nonce = ?
-
where did = ?`,
-
dpopPdsNonce,
-
did,
-
)
-
return err
-
}
+177
appview/models/profile.go
···
+
package models
+
+
import (
+
"fmt"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/api/tangled"
+
)
+
+
type Profile struct {
+
// ids
+
ID int
+
Did string
+
+
// data
+
Description string
+
IncludeBluesky bool
+
Location string
+
Links [5]string
+
Stats [2]VanityStat
+
PinnedRepos [6]syntax.ATURI
+
}
+
+
func (p Profile) IsLinksEmpty() bool {
+
for _, l := range p.Links {
+
if l != "" {
+
return false
+
}
+
}
+
return true
+
}
+
+
func (p Profile) IsStatsEmpty() bool {
+
for _, s := range p.Stats {
+
if s.Kind != "" {
+
return false
+
}
+
}
+
return true
+
}
+
+
func (p Profile) IsPinnedReposEmpty() bool {
+
for _, r := range p.PinnedRepos {
+
if r != "" {
+
return false
+
}
+
}
+
return true
+
}
+
+
type VanityStatKind string
+
+
const (
+
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
+
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
+
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
+
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
+
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
+
VanityStatRepositoryCount VanityStatKind = "repository-count"
+
)
+
+
func (v VanityStatKind) String() string {
+
switch v {
+
case VanityStatMergedPRCount:
+
return "Merged PRs"
+
case VanityStatClosedPRCount:
+
return "Closed PRs"
+
case VanityStatOpenPRCount:
+
return "Open PRs"
+
case VanityStatOpenIssueCount:
+
return "Open Issues"
+
case VanityStatClosedIssueCount:
+
return "Closed Issues"
+
case VanityStatRepositoryCount:
+
return "Repositories"
+
}
+
return ""
+
}
+
+
type VanityStat struct {
+
Kind VanityStatKind
+
Value uint64
+
}
+
+
func (p *Profile) ProfileAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
+
}
+
+
type RepoEvent struct {
+
Repo *Repo
+
Source *Repo
+
}
+
+
type ProfileTimeline struct {
+
ByMonth []ByMonth
+
}
+
+
func (p *ProfileTimeline) IsEmpty() bool {
+
if p == nil {
+
return true
+
}
+
+
for _, m := range p.ByMonth {
+
if !m.IsEmpty() {
+
return false
+
}
+
}
+
+
return true
+
}
+
+
type ByMonth struct {
+
RepoEvents []RepoEvent
+
IssueEvents IssueEvents
+
PullEvents PullEvents
+
}
+
+
func (b ByMonth) IsEmpty() bool {
+
return len(b.RepoEvents) == 0 &&
+
len(b.IssueEvents.Items) == 0 &&
+
len(b.PullEvents.Items) == 0
+
}
+
+
type IssueEvents struct {
+
Items []*Issue
+
}
+
+
type IssueEventStats struct {
+
Open int
+
Closed int
+
}
+
+
func (i IssueEvents) Stats() IssueEventStats {
+
var open, closed int
+
for _, issue := range i.Items {
+
if issue.Open {
+
open += 1
+
} else {
+
closed += 1
+
}
+
}
+
+
return IssueEventStats{
+
Open: open,
+
Closed: closed,
+
}
+
}
+
+
type PullEvents struct {
+
Items []*Pull
+
}
+
+
func (p PullEvents) Stats() PullEventStats {
+
var open, merged, closed int
+
for _, pull := range p.Items {
+
switch pull.State {
+
case PullOpen:
+
open += 1
+
case PullMerged:
+
merged += 1
+
case PullClosed:
+
closed += 1
+
}
+
}
+
+
return PullEventStats{
+
Open: open,
+
Merged: merged,
+
Closed: closed,
+
}
+
}
+
+
type PullEventStats struct {
+
Closed int
+
Open int
+
Merged int
+
}
+17 -139
appview/db/pipeline.go
···
"strings"
"time"
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
"github.com/go-git/go-git/v5/plumbing"
-
spindle "tangled.org/core/spindle/models"
-
"tangled.org/core/workflow"
+
"tangled.org/core/appview/models"
)
-
type Pipeline struct {
-
Id int
-
Rkey string
-
Knot string
-
RepoOwner syntax.DID
-
RepoName string
-
TriggerId int
-
Sha string
-
Created time.Time
-
-
// populate when querying for reverse mappings
-
Trigger *Trigger
-
Statuses map[string]WorkflowStatus
-
}
-
-
type WorkflowStatus struct {
-
Data []PipelineStatus
-
}
-
-
func (w WorkflowStatus) Latest() PipelineStatus {
-
return w.Data[len(w.Data)-1]
-
}
-
-
// time taken by this workflow to reach an "end state"
-
func (w WorkflowStatus) TimeTaken() time.Duration {
-
var start, end *time.Time
-
for _, s := range w.Data {
-
if s.Status.IsStart() {
-
start = &s.Created
-
}
-
if s.Status.IsFinish() {
-
end = &s.Created
-
}
-
}
-
-
if start != nil && end != nil && end.After(*start) {
-
return end.Sub(*start)
-
}
-
-
return 0
-
}
-
-
func (p Pipeline) Counts() map[string]int {
-
m := make(map[string]int)
-
for _, w := range p.Statuses {
-
m[w.Latest().Status.String()] += 1
-
}
-
return m
-
}
-
-
func (p Pipeline) TimeTaken() time.Duration {
-
var s time.Duration
-
for _, w := range p.Statuses {
-
s += w.TimeTaken()
-
}
-
return s
-
}
-
-
func (p Pipeline) Workflows() []string {
-
var ws []string
-
for v := range p.Statuses {
-
ws = append(ws, v)
-
}
-
slices.Sort(ws)
-
return ws
-
}
-
-
// if we know that a spindle has picked up this pipeline, then it is Responding
-
func (p Pipeline) IsResponding() bool {
-
return len(p.Statuses) != 0
-
}
-
-
type Trigger struct {
-
Id int
-
Kind workflow.TriggerKind
-
-
// push trigger fields
-
PushRef *string
-
PushNewSha *string
-
PushOldSha *string
-
-
// pull request trigger fields
-
PRSourceBranch *string
-
PRTargetBranch *string
-
PRSourceSha *string
-
PRAction *string
-
}
-
-
func (t *Trigger) IsPush() bool {
-
return t != nil && t.Kind == workflow.TriggerKindPush
-
}
-
-
func (t *Trigger) IsPullRequest() bool {
-
return t != nil && t.Kind == workflow.TriggerKindPullRequest
-
}
-
-
func (t *Trigger) TargetRef() string {
-
if t.IsPush() {
-
return plumbing.ReferenceName(*t.PushRef).Short()
-
} else if t.IsPullRequest() {
-
return *t.PRTargetBranch
-
}
-
-
return ""
-
}
-
-
type PipelineStatus struct {
-
ID int
-
Spindle string
-
Rkey string
-
PipelineKnot string
-
PipelineRkey string
-
Created time.Time
-
Workflow string
-
Status spindle.StatusKind
-
Error *string
-
ExitCode int
-
}
-
-
func GetPipelines(e Execer, filters ...filter) ([]Pipeline, error) {
-
var pipelines []Pipeline
+
func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) {
+
var pipelines []models.Pipeline
var conditions []string
var args []any
···
defer rows.Close()
for rows.Next() {
-
var pipeline Pipeline
+
var pipeline models.Pipeline
var createdAt string
err = rows.Scan(
&pipeline.Id,
···
return pipelines, nil
}
-
func AddPipeline(e Execer, pipeline Pipeline) error {
+
func AddPipeline(e Execer, pipeline models.Pipeline) error {
args := []any{
pipeline.Rkey,
pipeline.Knot,
···
return err
}
-
func AddTrigger(e Execer, trigger Trigger) (int64, error) {
+
func AddTrigger(e Execer, trigger models.Trigger) (int64, error) {
args := []any{
trigger.Kind,
trigger.PushRef,
···
return res.LastInsertId()
}
-
func AddPipelineStatus(e Execer, status PipelineStatus) error {
+
func AddPipelineStatus(e Execer, status models.PipelineStatus) error {
args := []any{
status.Spindle,
status.Rkey,
···
// this is a mega query, but the most useful one:
// get N pipelines, for each one get the latest status of its N workflows
-
func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) {
+
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
var conditions []string
var args []any
for _, filter := range filters {
···
}
defer rows.Close()
-
pipelines := make(map[string]Pipeline)
+
pipelines := make(map[string]models.Pipeline)
for rows.Next() {
-
var p Pipeline
-
var t Trigger
+
var p models.Pipeline
+
var t models.Trigger
var created string
err := rows.Scan(
···
t.Id = p.TriggerId
p.Trigger = &t
-
p.Statuses = make(map[string]WorkflowStatus)
+
p.Statuses = make(map[string]models.WorkflowStatus)
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
pipelines[k] = p
···
defer rows.Close()
for rows.Next() {
-
var ps PipelineStatus
+
var ps models.PipelineStatus
var created string
err := rows.Scan(
···
}
statuses, _ := pipeline.Statuses[ps.Workflow]
if !ok {
-
pipeline.Statuses[ps.Workflow] = WorkflowStatus{}
+
pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{}
}
// append
···
pipelines[key] = pipeline
}
-
var all []Pipeline
+
var all []models.Pipeline
for _, p := range pipelines {
for _, s := range p.Statuses {
-
slices.SortFunc(s.Data, func(a, b PipelineStatus) int {
+
slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int {
if a.Created.After(b.Created) {
return 1
}
···
}
// sort pipelines by date
-
slices.SortFunc(all, func(a, b Pipeline) int {
+
slices.SortFunc(all, func(a, b models.Pipeline) int {
if a.Created.After(b.Created) {
return -1
}
+130
appview/models/pipeline.go
···
+
package models
+
+
import (
+
"slices"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/go-git/go-git/v5/plumbing"
+
spindle "tangled.org/core/spindle/models"
+
"tangled.org/core/workflow"
+
)
+
+
type Pipeline struct {
+
Id int
+
Rkey string
+
Knot string
+
RepoOwner syntax.DID
+
RepoName string
+
TriggerId int
+
Sha string
+
Created time.Time
+
+
// populate when querying for reverse mappings
+
Trigger *Trigger
+
Statuses map[string]WorkflowStatus
+
}
+
+
type WorkflowStatus struct {
+
Data []PipelineStatus
+
}
+
+
func (w WorkflowStatus) Latest() PipelineStatus {
+
return w.Data[len(w.Data)-1]
+
}
+
+
// time taken by this workflow to reach an "end state"
+
func (w WorkflowStatus) TimeTaken() time.Duration {
+
var start, end *time.Time
+
for _, s := range w.Data {
+
if s.Status.IsStart() {
+
start = &s.Created
+
}
+
if s.Status.IsFinish() {
+
end = &s.Created
+
}
+
}
+
+
if start != nil && end != nil && end.After(*start) {
+
return end.Sub(*start)
+
}
+
+
return 0
+
}
+
+
func (p Pipeline) Counts() map[string]int {
+
m := make(map[string]int)
+
for _, w := range p.Statuses {
+
m[w.Latest().Status.String()] += 1
+
}
+
return m
+
}
+
+
func (p Pipeline) TimeTaken() time.Duration {
+
var s time.Duration
+
for _, w := range p.Statuses {
+
s += w.TimeTaken()
+
}
+
return s
+
}
+
+
func (p Pipeline) Workflows() []string {
+
var ws []string
+
for v := range p.Statuses {
+
ws = append(ws, v)
+
}
+
slices.Sort(ws)
+
return ws
+
}
+
+
// if we know that a spindle has picked up this pipeline, then it is Responding
+
func (p Pipeline) IsResponding() bool {
+
return len(p.Statuses) != 0
+
}
+
+
type Trigger struct {
+
Id int
+
Kind workflow.TriggerKind
+
+
// push trigger fields
+
PushRef *string
+
PushNewSha *string
+
PushOldSha *string
+
+
// pull request trigger fields
+
PRSourceBranch *string
+
PRTargetBranch *string
+
PRSourceSha *string
+
PRAction *string
+
}
+
+
func (t *Trigger) IsPush() bool {
+
return t != nil && t.Kind == workflow.TriggerKindPush
+
}
+
+
func (t *Trigger) IsPullRequest() bool {
+
return t != nil && t.Kind == workflow.TriggerKindPullRequest
+
}
+
+
func (t *Trigger) TargetRef() string {
+
if t.IsPush() {
+
return plumbing.ReferenceName(*t.PushRef).Short()
+
} else if t.IsPullRequest() {
+
return *t.PRTargetBranch
+
}
+
+
return ""
+
}
+
+
type PipelineStatus struct {
+
ID int
+
Spindle string
+
Rkey string
+
PipelineKnot string
+
PipelineRkey string
+
Created time.Time
+
Workflow string
+
Status spindle.StatusKind
+
Error *string
+
ExitCode int
+
}
+3 -2
appview/repo/repo_util.go
···
"strings"
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
"tangled.org/core/appview/pages/repoinfo"
"tangled.org/core/types"
···
d *db.DB,
repoInfo repoinfo.RepoInfo,
shas []string,
-
) (map[string]db.Pipeline, error) {
-
m := make(map[string]db.Pipeline)
+
) (map[string]models.Pipeline, error) {
+
m := make(map[string]models.Pipeline)
if len(shas) == 0 {
return m, nil
+2 -1
appview/state/spindlestream.go
···
"tangled.org/core/appview/cache"
"tangled.org/core/appview/config"
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
ec "tangled.org/core/eventconsumer"
"tangled.org/core/eventconsumer/cursor"
"tangled.org/core/log"
···
created = t
}
-
status := db.PipelineStatus{
+
status := models.PipelineStatus{
Spindle: source.Key(),
Rkey: msg.Rkey,
PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
+2 -1
appview/commitverify/verify.go
···
"github.com/go-git/go-git/v5/plumbing/object"
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
"tangled.org/core/crypto"
"tangled.org/core/types"
)
···
func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) {
vcs := VerifiedCommits{}
-
didPubkeyCache := make(map[string][]db.PublicKey)
+
didPubkeyCache := make(map[string][]models.PublicKey)
for _, commit := range ndCommits {
c := commit.Commit
+7 -26
appview/db/pubkeys.go
···
package db
import (
-
"encoding/json"
+
"tangled.org/core/appview/models"
"time"
)
···
return err
}
-
type PublicKey struct {
-
Did string `json:"did"`
-
Key string `json:"key"`
-
Name string `json:"name"`
-
Rkey string `json:"rkey"`
-
Created *time.Time
-
}
-
-
func (p PublicKey) MarshalJSON() ([]byte, error) {
-
type Alias PublicKey
-
return json.Marshal(&struct {
-
Created string `json:"created"`
-
*Alias
-
}{
-
Created: p.Created.Format(time.RFC3339),
-
Alias: (*Alias)(&p),
-
})
-
}
-
-
func GetAllPublicKeys(e Execer) ([]PublicKey, error) {
-
var keys []PublicKey
+
func GetAllPublicKeys(e Execer) ([]models.PublicKey, error) {
+
var keys []models.PublicKey
rows, err := e.Query(`select key, name, did, rkey, created from public_keys`)
if err != nil {
···
defer rows.Close()
for rows.Next() {
-
var publicKey PublicKey
+
var publicKey models.PublicKey
var createdAt string
if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil {
return nil, err
···
return keys, nil
}
-
func GetPublicKeysForDid(e Execer, did string) ([]PublicKey, error) {
-
var keys []PublicKey
+
func GetPublicKeysForDid(e Execer, did string) ([]models.PublicKey, error) {
+
var keys []models.PublicKey
rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did)
if err != nil {
···
defer rows.Close()
for rows.Next() {
-
var publicKey PublicKey
+
var publicKey models.PublicKey
var createdAt string
if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil {
return nil, err
+25
appview/models/pubkey.go
···
+
package models
+
+
import (
+
"encoding/json"
+
"time"
+
)
+
+
type PublicKey struct {
+
Did string `json:"did"`
+
Key string `json:"key"`
+
Name string `json:"name"`
+
Rkey string `json:"rkey"`
+
Created *time.Time
+
}
+
+
func (p PublicKey) MarshalJSON() ([]byte, error) {
+
type Alias PublicKey
+
return json.Marshal(&struct {
+
Created string `json:"created"`
+
*Alias
+
}{
+
Created: p.Created.Format(time.RFC3339),
+
Alias: (*Alias)(&p),
+
})
+
}
+7 -16
appview/db/punchcard.go
···
"fmt"
"strings"
"time"
-
)
-
type Punch struct {
-
Did string
-
Date time.Time
-
Count int
-
}
+
"tangled.org/core/appview/models"
+
)
// this adds to the existing count
-
func AddPunch(e Execer, punch Punch) error {
+
func AddPunch(e Execer, punch models.Punch) error {
_, err := e.Exec(`
insert into punchcard (did, date, count)
values (?, ?, ?)
···
return err
}
-
type Punchcard struct {
-
Total int
-
Punches []Punch
-
}
-
-
func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) {
-
punchcard := &Punchcard{}
+
func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) {
+
punchcard := &models.Punchcard{}
now := time.Now()
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC)
for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) {
-
punchcard.Punches = append(punchcard.Punches, Punch{
+
punchcard.Punches = append(punchcard.Punches, models.Punch{
Date: d,
Count: 0,
})
···
defer rows.Close()
for rows.Next() {
-
var punch Punch
+
var punch models.Punch
var date string
var count sql.NullInt64
if err := rows.Scan(&date, &count); err != nil {
+14
appview/models/punchcard.go
···
+
package models
+
+
import "time"
+
+
type Punch struct {
+
Did string
+
Date time.Time
+
Count int
+
}
+
+
type Punchcard struct {
+
Total int
+
Punches []Punch
+
}
+4 -43
appview/db/registration.go
···
"fmt"
"strings"
"time"
-
)
-
-
// Registration represents a knot registration. Knot would've been a better
-
// name but we're stuck with this for historical reasons.
-
type Registration struct {
-
Id int64
-
Domain string
-
ByDid string
-
Created *time.Time
-
Registered *time.Time
-
NeedsUpgrade bool
-
}
-
-
func (r *Registration) Status() Status {
-
if r.NeedsUpgrade {
-
return NeedsUpgrade
-
} else if r.Registered != nil {
-
return Registered
-
} else {
-
return Pending
-
}
-
}
-
-
func (r *Registration) IsRegistered() bool {
-
return r.Status() == Registered
-
}
-
-
func (r *Registration) IsNeedsUpgrade() bool {
-
return r.Status() == NeedsUpgrade
-
}
-
-
func (r *Registration) IsPending() bool {
-
return r.Status() == Pending
-
}
-
-
type Status uint32
-
const (
-
Registered Status = iota
-
Pending
-
NeedsUpgrade
+
"tangled.org/core/appview/models"
)
-
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
-
var registrations []Registration
+
func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) {
+
var registrations []models.Registration
var conditions []string
var args []any
···
var createdAt string
var registeredAt sql.Null[string]
var needsUpgrade int
-
var reg Registration
+
var reg models.Registration
err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade)
if err != nil {
+44
appview/models/registration.go
···
+
package models
+
+
import "time"
+
+
// Registration represents a knot registration. Knot would've been a better
+
// name but we're stuck with this for historical reasons.
+
type Registration struct {
+
Id int64
+
Domain string
+
ByDid string
+
Created *time.Time
+
Registered *time.Time
+
NeedsUpgrade bool
+
}
+
+
func (r *Registration) Status() Status {
+
if r.NeedsUpgrade {
+
return NeedsUpgrade
+
} else if r.Registered != nil {
+
return Registered
+
} else {
+
return Pending
+
}
+
}
+
+
func (r *Registration) IsRegistered() bool {
+
return r.Status() == Registered
+
}
+
+
func (r *Registration) IsNeedsUpgrade() bool {
+
return r.Status() == NeedsUpgrade
+
}
+
+
func (r *Registration) IsPending() bool {
+
return r.Status() == Pending
+
}
+
+
type Status uint32
+
+
const (
+
Registered Status = iota
+
Pending
+
NeedsUpgrade
+
)
+14 -63
appview/db/reaction.go
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/appview/models"
)
-
type ReactionKind string
-
-
const (
-
Like ReactionKind = "๐Ÿ‘"
-
Unlike ReactionKind = "๐Ÿ‘Ž"
-
Laugh ReactionKind = "๐Ÿ˜†"
-
Celebration ReactionKind = "๐ŸŽ‰"
-
Confused ReactionKind = "๐Ÿซค"
-
Heart ReactionKind = "โค๏ธ"
-
Rocket ReactionKind = "๐Ÿš€"
-
Eyes ReactionKind = "๐Ÿ‘€"
-
)
-
-
func (rk ReactionKind) String() string {
-
return string(rk)
-
}
-
-
var OrderedReactionKinds = []ReactionKind{
-
Like,
-
Unlike,
-
Laugh,
-
Celebration,
-
Confused,
-
Heart,
-
Rocket,
-
Eyes,
-
}
-
-
func ParseReactionKind(raw string) (ReactionKind, bool) {
-
k, ok := (map[string]ReactionKind{
-
"๐Ÿ‘": Like,
-
"๐Ÿ‘Ž": Unlike,
-
"๐Ÿ˜†": Laugh,
-
"๐ŸŽ‰": Celebration,
-
"๐Ÿซค": Confused,
-
"โค๏ธ": Heart,
-
"๐Ÿš€": Rocket,
-
"๐Ÿ‘€": Eyes,
-
})[raw]
-
return k, ok
-
}
-
-
type Reaction struct {
-
ReactedByDid string
-
ThreadAt syntax.ATURI
-
Created time.Time
-
Rkey string
-
Kind ReactionKind
-
}
-
-
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error {
+
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error {
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
return err
}
// Get a reaction record
-
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) {
+
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) {
query := `
select reacted_by_did, thread_at, created, rkey
from reactions
where reacted_by_did = ? and thread_at = ? and kind = ?`
row := e.QueryRow(query, reactedByDid, threadAt, kind)
-
var reaction Reaction
+
var reaction models.Reaction
var created string
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
if err != nil {
···
}
// Remove a reaction
-
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error {
+
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error {
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
return err
}
···
return err
}
-
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) {
+
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) {
count := 0
err := e.QueryRow(
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
···
return count, nil
}
-
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) {
-
countMap := map[ReactionKind]int{}
-
for _, kind := range OrderedReactionKinds {
+
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
+
countMap := map[models.ReactionKind]int{}
+
for _, kind := range models.OrderedReactionKinds {
count, err := GetReactionCount(e, threadAt, kind)
if err != nil {
-
return map[ReactionKind]int{}, nil
+
return map[models.ReactionKind]int{}, nil
}
countMap[kind] = count
}
return countMap, nil
}
-
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool {
+
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
return false
} else {
···
}
}
-
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool {
-
statusMap := map[ReactionKind]bool{}
-
for _, kind := range OrderedReactionKinds {
+
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool {
+
statusMap := map[models.ReactionKind]bool{}
+
for _, kind := range models.OrderedReactionKinds {
count := GetReactionStatus(e, userDid, threadAt, kind)
statusMap[kind] = count
}
+57
appview/models/reaction.go
···
+
package models
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type ReactionKind string
+
+
const (
+
Like ReactionKind = "๐Ÿ‘"
+
Unlike ReactionKind = "๐Ÿ‘Ž"
+
Laugh ReactionKind = "๐Ÿ˜†"
+
Celebration ReactionKind = "๐ŸŽ‰"
+
Confused ReactionKind = "๐Ÿซค"
+
Heart ReactionKind = "โค๏ธ"
+
Rocket ReactionKind = "๐Ÿš€"
+
Eyes ReactionKind = "๐Ÿ‘€"
+
)
+
+
func (rk ReactionKind) String() string {
+
return string(rk)
+
}
+
+
var OrderedReactionKinds = []ReactionKind{
+
Like,
+
Unlike,
+
Laugh,
+
Celebration,
+
Confused,
+
Heart,
+
Rocket,
+
Eyes,
+
}
+
+
func ParseReactionKind(raw string) (ReactionKind, bool) {
+
k, ok := (map[string]ReactionKind{
+
"๐Ÿ‘": Like,
+
"๐Ÿ‘Ž": Unlike,
+
"๐Ÿ˜†": Laugh,
+
"๐ŸŽ‰": Celebration,
+
"๐Ÿซค": Confused,
+
"โค๏ธ": Heart,
+
"๐Ÿš€": Rocket,
+
"๐Ÿ‘€": Eyes,
+
})[raw]
+
return k, ok
+
}
+
+
type Reaction struct {
+
ReactedByDid string
+
ThreadAt syntax.ATURI
+
Created time.Time
+
Rkey string
+
Kind ReactionKind
+
}
+2 -1
appview/state/reaction.go
···
lexutil "github.com/bluesky-social/indigo/lex/util"
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/models"
"tangled.org/core/appview/pages"
"tangled.org/core/tid"
)
···
return
}
-
reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind"))
+
reactionKind, ok := models.ParseReactionKind(r.URL.Query().Get("kind"))
if !ok {
log.Println("invalid reaction kind")
return
+4 -9
appview/db/signup.go
···
package db
-
import "time"
+
import (
+
"tangled.org/core/appview/models"
+
)
-
type InflightSignup struct {
-
Id int64
-
Email string
-
InviteCode string
-
Created time.Time
-
}
-
-
func AddInflightSignup(e Execer, signup InflightSignup) error {
+
func AddInflightSignup(e Execer, signup models.InflightSignup) error {
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
_, err := e.Exec(query, signup.Email, signup.InviteCode)
return err
+10
appview/models/signup.go
···
+
package models
+
+
import "time"
+
+
type InflightSignup struct {
+
Id int64
+
Email string
+
InviteCode string
+
Created time.Time
+
}
+9 -27
appview/db/spindle.go
···
"strings"
"time"
-
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/appview/models"
)
-
type Spindle struct {
-
Id int
-
Owner syntax.DID
-
Instance string
-
Verified *time.Time
-
Created time.Time
-
NeedsUpgrade bool
-
}
-
-
type SpindleMember struct {
-
Id int
-
Did syntax.DID // owner of the record
-
Rkey string // rkey of the record
-
Instance string
-
Subject syntax.DID // the member being added
-
Created time.Time
-
}
-
-
func GetSpindles(e Execer, filters ...filter) ([]Spindle, error) {
-
var spindles []Spindle
+
func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) {
+
var spindles []models.Spindle
var conditions []string
var args []any
···
defer rows.Close()
for rows.Next() {
-
var spindle Spindle
+
var spindle models.Spindle
var createdAt string
var verified sql.NullString
var needsUpgrade int
···
}
// if there is an existing spindle with the same instance, this returns an error
-
func AddSpindle(e Execer, spindle Spindle) error {
+
func AddSpindle(e Execer, spindle models.Spindle) error {
_, err := e.Exec(
`insert into spindles (owner, instance) values (?, ?)`,
spindle.Owner,
···
return err
}
-
func AddSpindleMember(e Execer, member SpindleMember) error {
+
func AddSpindleMember(e Execer, member models.SpindleMember) error {
_, err := e.Exec(
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
member.Did,
···
return err
}
-
func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) {
-
var members []SpindleMember
+
func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) {
+
var members []models.SpindleMember
var conditions []string
var args []any
···
defer rows.Close()
for rows.Next() {
-
var member SpindleMember
+
var member models.SpindleMember
var createdAt string
if err := rows.Scan(
+25
appview/models/spindle.go
···
+
package models
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type Spindle struct {
+
Id int
+
Owner syntax.DID
+
Instance string
+
Verified *time.Time
+
Created time.Time
+
NeedsUpgrade bool
+
}
+
+
type SpindleMember struct {
+
Id int
+
Did syntax.DID // owner of the record
+
Rkey string // rkey of the record
+
Instance string
+
Subject syntax.DID // the member being added
+
Created time.Time
+
}
+17
appview/models/star.go
···
+
package models
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type Star struct {
+
StarredByDid string
+
RepoAt syntax.ATURI
+
Created time.Time
+
Rkey string
+
+
// optionally, populate this when querying for reverse mappings
+
Repo *Repo
+
}
+5 -110
appview/db/strings.go
···
package db
import (
-
"bytes"
"database/sql"
"errors"
"fmt"
-
"io"
"strings"
"time"
-
"unicode/utf8"
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/models"
)
-
type String struct {
-
Did syntax.DID
-
Rkey string
-
-
Filename string
-
Description string
-
Contents string
-
Created time.Time
-
Edited *time.Time
-
}
-
-
func (s *String) StringAt() syntax.ATURI {
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
-
}
-
-
type StringStats struct {
-
LineCount uint64
-
ByteCount uint64
-
}
-
-
func (s String) Stats() StringStats {
-
lineCount, err := countLines(strings.NewReader(s.Contents))
-
if err != nil {
-
// non-fatal
-
// TODO: log this?
-
}
-
-
return StringStats{
-
LineCount: uint64(lineCount),
-
ByteCount: uint64(len(s.Contents)),
-
}
-
}
-
-
func (s String) Validate() error {
-
var err error
-
-
if utf8.RuneCountInString(s.Filename) > 140 {
-
err = errors.Join(err, fmt.Errorf("filename too long"))
-
}
-
-
if utf8.RuneCountInString(s.Description) > 280 {
-
err = errors.Join(err, fmt.Errorf("description too long"))
-
}
-
-
if len(s.Contents) == 0 {
-
err = errors.Join(err, fmt.Errorf("contents is empty"))
-
}
-
-
return err
-
}
-
-
func (s *String) AsRecord() tangled.String {
-
return tangled.String{
-
Filename: s.Filename,
-
Description: s.Description,
-
Contents: s.Contents,
-
CreatedAt: s.Created.Format(time.RFC3339),
-
}
-
}
-
-
func StringFromRecord(did, rkey string, record tangled.String) String {
-
created, err := time.Parse(record.CreatedAt, time.RFC3339)
-
if err != nil {
-
created = time.Now()
-
}
-
return String{
-
Did: syntax.DID(did),
-
Rkey: rkey,
-
Filename: record.Filename,
-
Description: record.Description,
-
Contents: record.Contents,
-
Created: created,
-
}
-
}
-
-
func AddString(e Execer, s String) error {
+
func AddString(e Execer, s models.String) error {
_, err := e.Exec(
`insert into strings (
did,
···
return err
}
-
func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
-
var all []String
+
func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) {
+
var all []models.String
var conditions []string
var args []any
···
defer rows.Close()
for rows.Next() {
-
var s String
+
var s models.String
var createdAt string
var editedAt sql.NullString
···
_, err := e.Exec(query, args...)
return err
}
-
-
func countLines(r io.Reader) (int, error) {
-
buf := make([]byte, 32*1024)
-
bufLen := 0
-
count := 0
-
nl := []byte{'\n'}
-
-
for {
-
c, err := r.Read(buf)
-
if c > 0 {
-
bufLen += c
-
}
-
count += bytes.Count(buf[:c], nl)
-
-
switch {
-
case err == io.EOF:
-
/* handle last line not having a newline at the end */
-
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
-
count++
-
}
-
return count, nil
-
case err != nil:
-
return 0, err
-
}
-
}
-
}
+95
appview/models/string.go
···
+
package models
+
+
import (
+
"bytes"
+
"fmt"
+
"io"
+
"strings"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/api/tangled"
+
)
+
+
type String struct {
+
Did syntax.DID
+
Rkey string
+
+
Filename string
+
Description string
+
Contents string
+
Created time.Time
+
Edited *time.Time
+
}
+
+
func (s *String) StringAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
+
}
+
+
func (s *String) AsRecord() tangled.String {
+
return tangled.String{
+
Filename: s.Filename,
+
Description: s.Description,
+
Contents: s.Contents,
+
CreatedAt: s.Created.Format(time.RFC3339),
+
}
+
}
+
+
func StringFromRecord(did, rkey string, record tangled.String) String {
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
+
if err != nil {
+
created = time.Now()
+
}
+
return String{
+
Did: syntax.DID(did),
+
Rkey: rkey,
+
Filename: record.Filename,
+
Description: record.Description,
+
Contents: record.Contents,
+
Created: created,
+
}
+
}
+
+
type StringStats struct {
+
LineCount uint64
+
ByteCount uint64
+
}
+
+
func (s String) Stats() StringStats {
+
lineCount, err := countLines(strings.NewReader(s.Contents))
+
if err != nil {
+
// non-fatal
+
// TODO: log this?
+
}
+
+
return StringStats{
+
LineCount: uint64(lineCount),
+
ByteCount: uint64(len(s.Contents)),
+
}
+
}
+
+
func countLines(r io.Reader) (int, error) {
+
buf := make([]byte, 32*1024)
+
bufLen := 0
+
count := 0
+
nl := []byte{'\n'}
+
+
for {
+
c, err := r.Read(buf)
+
if c > 0 {
+
bufLen += c
+
}
+
count += bytes.Count(buf[:c], nl)
+
+
switch {
+
case err == io.EOF:
+
/* handle last line not having a newline at the end */
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
+
count++
+
}
+
return count, nil
+
case err != nil:
+
return 0, err
+
}
+
}
+
}
+2 -3
appview/posthog/notifier.go
···
"log"
"github.com/posthog/posthog-go"
-
"tangled.org/core/appview/db"
"tangled.org/core/appview/models"
"tangled.org/core/appview/notify"
)
···
}
}
-
func (n *posthogNotifier) EditString(ctx context.Context, string *db.String) {
+
func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: string.Did.String(),
Event: "edit_string",
···
}
}
-
func (n *posthogNotifier) CreateString(ctx context.Context, string *db.String) {
+
func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: string.Did.String(),
Event: "create_string",
+3 -2
appview/strings/strings.go
···
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
"tangled.org/core/appview/middleware"
+
"tangled.org/core/appview/models"
"tangled.org/core/appview/notify"
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
···
description := r.FormValue("description")
// construct new string from form values
-
entry := db.String{
+
entry := models.String{
Did: first.Did,
Rkey: first.Rkey,
Filename: filename,
···
description := r.FormValue("description")
-
string := db.String{
+
string := models.String{
Did: syntax.DID(user.Did),
Rkey: tid.TID(),
Filename: filename,
+27
appview/validator/string.go
···
+
package validator
+
+
import (
+
"errors"
+
"fmt"
+
"unicode/utf8"
+
+
"tangled.org/core/appview/models"
+
)
+
+
func (v *Validator) ValidateString(s *models.String) error {
+
var err error
+
+
if utf8.RuneCountInString(s.Filename) > 140 {
+
err = errors.Join(err, fmt.Errorf("filename too long"))
+
}
+
+
if utf8.RuneCountInString(s.Description) > 280 {
+
err = errors.Join(err, fmt.Errorf("description too long"))
+
}
+
+
if len(s.Contents) == 0 {
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
+
}
+
+
return err
+
}
+11 -32
appview/db/timeline.go
···
import (
"sort"
-
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
)
-
type TimelineEvent struct {
-
*models.Repo
-
*models.Follow
-
*models.Star
-
-
EventAt time.Time
-
-
// optional: populate only if Repo is a fork
-
Source *models.Repo
-
-
// optional: populate only if event is Follow
-
*models.Profile
-
*models.FollowStats
-
*models.FollowStatus
-
-
// optional: populate only if event is Repo
-
IsStarred bool
-
StarCount int64
-
}
-
// TODO: this gathers heterogenous events from different sources and aggregates
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
-
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
-
var events []TimelineEvent
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
+
var events []models.TimelineEvent
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
if err != nil {
···
return isStarred, starCount
}
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
+
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
repos, err := GetRepos(e, limit)
if err != nil {
return nil, err
···
return nil, err
}
-
var events []TimelineEvent
+
var events []models.TimelineEvent
for _, r := range repos {
var source *models.Repo
if r.Source != "" {
···
isStarred, starCount := getRepoStarInfo(&r, starStatuses)
-
events = append(events, TimelineEvent{
+
events = append(events, models.TimelineEvent{
Repo: &r,
EventAt: r.Created,
Source: source,
···
return events, nil
}
-
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
+
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
stars, err := GetStars(e, limit)
if err != nil {
return nil, err
···
return nil, err
}
-
var events []TimelineEvent
+
var events []models.TimelineEvent
for _, s := range stars {
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
-
events = append(events, TimelineEvent{
+
events = append(events, models.TimelineEvent{
Star: &s,
EventAt: s.Created,
IsStarred: isStarred,
···
return events, nil
}
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
+
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
follows, err := GetFollows(e, limit)
if err != nil {
return nil, err
···
}
}
-
var events []TimelineEvent
+
var events []models.TimelineEvent
for _, f := range follows {
profile, _ := profiles[f.SubjectDid]
followStatMap, _ := followStatMap[f.SubjectDid]
···
followStatus = followStatuses[f.SubjectDid]
}
-
events = append(events, TimelineEvent{
+
events = append(events, models.TimelineEvent{
Follow: &f,
Profile: profile,
FollowStats: &followStatMap,
+23
appview/models/timeline.go
···
+
package models
+
+
import "time"
+
+
type TimelineEvent struct {
+
*Repo
+
*Follow
+
*Star
+
+
EventAt time.Time
+
+
// optional: populate only if Repo is a fork
+
Source *Repo
+
+
// optional: populate only if event is Follow
+
*Profile
+
*FollowStats
+
*FollowStatus
+
+
// optional: populate only if event is Repo
+
IsStarred bool
+
StarCount int64
+
}
+1 -1
appview/db/label.go
···
defs[l.AtUri().String()] = &l
}
-
return &models.LabelApplicationCtx{defs}, nil
+
return &models.LabelApplicationCtx{Defs: defs}, nil
}
+30
appview/pages/funcmap_test.go
···
+
package pages
+
+
import (
+
"html/template"
+
"tangled.org/core/appview/config"
+
"tangled.org/core/idresolver"
+
"testing"
+
)
+
+
func TestPages_funcMap(t *testing.T) {
+
tests := []struct {
+
name string // description of this test case
+
// Named input parameters for receiver constructor.
+
config *config.Config
+
res *idresolver.Resolver
+
want template.FuncMap
+
}{
+
// TODO: Add test cases.
+
}
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
p := NewPages(tt.config, tt.res)
+
got := p.funcMap()
+
// TODO: update the condition below to compare got with tt.want.
+
if true {
+
t.Errorf("funcMap() = %v, want %v", got, tt.want)
+
}
+
})
+
}
+
}
+1 -1
appview/reporesolver/resolver.go
···
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
if u != nil {
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
-
return repoinfo.RolesInRepo{r}
+
return repoinfo.RolesInRepo{Roles: r}
} else {
return repoinfo.RolesInRepo{}
}
+6
.tangled/workflows/test.yml
···
command: |
mkdir -p appview/pages/static; touch appview/pages/static/x
+
- name: run linter
+
environment:
+
CGO_ENABLED: 1
+
command: |
+
go vet -v ./...
+
- name: run all tests
environment:
CGO_ENABLED: 1
+1 -1
knotserver/ingester.go
···
return fmt.Errorf("failed to construct absolute repo path: %w", err)
}
-
gr, err := git.Open(repoPath, record.Source.Branch)
+
gr, err := git.Open(repoPath, record.Source.Sha)
if err != nil {
return fmt.Errorf("failed to open git repository: %w", err)
}
+11
appview/db/star.go
···
"errors"
"fmt"
"log"
+
"slices"
"strings"
"time"
···
stars = append(stars, s...)
}
+
slices.SortFunc(stars, func(a, b models.Star) int {
+
if a.Created.After(b.Created) {
+
return -1
+
}
+
if b.Created.After(a.Created) {
+
return 1
+
}
+
return 0
+
})
+
return stars, nil
}
+2 -3
appview/repo/router.go
···
r.Route("/tags", func(r chi.Router) {
r.Get("/", rp.RepoTags)
r.Route("/{tag}", func(r chi.Router) {
-
r.Use(middleware.AuthMiddleware(rp.oauth))
-
// require auth to download for now
r.Get("/download/{file}", rp.DownloadArtifact)
// require repo:push to upload or delete artifacts
···
// additionally: only the uploader can truly delete an artifact
// (record+blob will live on their pds)
r.Group(func(r chi.Router) {
-
r.With(mw.RepoPermissionMiddleware("repo:push"))
+
r.Use(middleware.AuthMiddleware(rp.oauth))
+
r.Use(mw.RepoPermissionMiddleware("repo:push"))
r.Post("/upload", rp.AttachArtifact)
r.Delete("/{file}", rp.DeleteArtifact)
})
+10 -33
appview/pages/templates/timeline/fragments/timeline.html
···
{{ $event := index . 1 }}
{{ $follow := $event.Follow }}
{{ $profile := $event.Profile }}
-
{{ $stat := $event.FollowStats }}
+
{{ $followStats := $event.FollowStats }}
+
{{ $followStatus := $event.FollowStatus }}
{{ $userHandle := resolve $follow.UserDid }}
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
{{ template "user/fragments/picHandleLink" $subjectHandle }}
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
</div>
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4">
-
<div class="flex items-center gap-4 flex-1">
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
-
</div>
-
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
-
<a href="/{{ $subjectHandle }}">
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
-
</a>
-
{{ with $profile }}
-
{{ with .Description }}
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
-
{{ end }}
-
{{ end }}
-
{{ with $stat }}
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
-
<span class="select-none after:content-['ยท']"></span>
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
-
</div>
-
{{ end }}
-
</div>
-
</div>
-
-
{{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }}
-
<div class="flex-shrink-0 w-fit ml-auto">
-
{{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }}
-
</div>
-
{{ end }}
-
</div>
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $root.LoggedInUser
+
"UserDid" $follow.SubjectDid
+
"Profile" $profile
+
"FollowStatus" $followStatus
+
"FollowersCount" $followStats.Followers
+
"FollowingCount" $followStats.Following) }}
{{ end }}
+8 -1
appview/pages/templates/user/followers.html
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Followers }}
-
{{ template "user/fragments/followCard" . }}
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $.LoggedInUser
+
"UserDid" .UserDid
+
"Profile" .Profile
+
"FollowStatus" .FollowStatus
+
"FollowersCount" .FollowersCount
+
"FollowingCount" .FollowingCount) }}
{{ else }}
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
{{ end }}
+8 -1
appview/pages/templates/user/following.html
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Following }}
-
{{ template "user/fragments/followCard" . }}
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $.LoggedInUser
+
"UserDid" .UserDid
+
"Profile" .Profile
+
"FollowStatus" .FollowStatus
+
"FollowersCount" .FollowersCount
+
"FollowingCount" .FollowingCount) }}
{{ else }}
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
{{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
···
{{ define "user/fragments/follow" }}
<button id="{{ normalizeForHtmlId .UserDid }}"
-
class="btn mt-2 flex gap-2 items-center group"
+
class="btn w-full flex gap-2 items-center group"
{{ if eq .FollowStatus.String "IsNotFollowing" }}
hx-post="/follow?subject={{.UserDid}}"
···
hx-target="#{{ normalizeForHtmlId .UserDid }}"
hx-swap="outerHTML"
>
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }}
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
+
{{ i "user-round-plus" "w-4 h-4" }} follow
+
{{ else }}
+
{{ i "user-round-minus" "w-4 h-4" }} unfollow
+
{{ end }}
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
{{ end }}
+10 -10
appview/pages/templates/user/fragments/repoCard.html
···
{{ with $repo }}
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
<div class="font-medium dark:text-white flex items-center justify-between">
-
<div class="flex items-center">
-
{{ if .Source }}
-
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
-
{{ else }}
-
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
-
{{ end }}
-
+
<div class="flex items-center min-w-0 flex-1 mr-2">
+
{{ if .Source }}
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
+
{{ else }}
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
+
{{ end }}
{{ $repoOwner := resolve .Did }}
{{- if $fullName -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a>
{{- else -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a>
{{- end -}}
</div>
-
{{ if and $starButton $root.LoggedInUser }}
+
<div class="shrink-0">
{{ template "repo/fragments/repoStar" $starData }}
+
</div>
{{ end }}
</div>
{{ with .Description }}
+1
appview/state/profile.go
···
profile.Did = did
}
followCards[i] = pages.FollowCard{
+
LoggedInUser: loggedInUser,
UserDid: did,
FollowStatus: followStatus,
FollowersCount: followStats.Followers,
+58 -3
appview/posthog/notifier.go appview/notify/posthog/notifier.go
···
-
package posthog_service
+
package posthog
import (
"context"
···
}
}
+
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: pull.OwnerDid,
+
Event: "pull_closed",
+
Properties: posthog.Properties{
+
"repo_at": pull.RepoAt,
+
"pull_id": pull.PullId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: follow.UserDid,
···
}
}
-
func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) {
+
func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: string.Did.String(),
-
Event: "create_string",
+
Event: "new_string",
Properties: posthog.Properties{"rkey": string.Rkey},
})
if err != nil {
log.Println("failed to enqueue posthog event:", err)
}
}
+
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: comment.Did,
+
Event: "new_issue_comment",
+
Properties: posthog.Properties{
+
"issue_at": comment.IssueAt,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: issue.Did,
+
Event: "issue_closed",
+
Properties: posthog.Properties{
+
"repo_at": issue.RepoAt.String(),
+
"issue_id": issue.IssueId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: pull.OwnerDid,
+
Event: "pull_merged",
+
Properties: posthog.Properties{
+
"repo_at": pull.RepoAt,
+
"pull_id": pull.PullId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+12
appview/notify/merged_notifier.go
···
}
}
+
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
+
for _, notifier := range m.notifiers {
+
notifier.NewPullMerged(ctx, pull)
+
}
+
}
+
+
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
+
for _, notifier := range m.notifiers {
+
notifier.NewPullClosed(ctx, pull)
+
}
+
}
+
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
for _, notifier := range m.notifiers {
notifier.UpdateProfile(ctx, profile)
+4
appview/notify/notifier.go
···
NewPull(ctx context.Context, pull *models.Pull)
NewPullComment(ctx context.Context, comment *models.PullComment)
+
NewPullMerged(ctx context.Context, pull *models.Pull)
+
NewPullClosed(ctx context.Context, pull *models.Pull)
UpdateProfile(ctx context.Context, profile *models.Profile)
···
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
+
func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {}
+
func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {}
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+4 -11
appview/pages/templates/errors/500.html
···
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
<div class="mb-6">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
+
{{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }}
</div>
</div>
···
500 &mdash; internal server error
</h1>
<p class="text-gray-600 dark:text-gray-300">
-
Something went wrong on our end. We've been notified and are working to fix the issue.
-
</p>
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
-
<div class="flex items-center gap-2">
-
{{ i "info" "w-4 h-4" }}
-
<span class="font-medium">we're on it!</span>
-
</div>
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
-
</div>
+
We encountered an error while processing your request. Please try again later.
+
</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
<button onclick="location.reload()" class="btn-create gap-2">
{{ i "refresh-cw" "w-4 h-4" }}
try again
</button>
<a href="/" class="btn no-underline hover:no-underline gap-2">
-
{{ i "home" "w-4 h-4" }}
+
{{ i "arrow-left" "w-4 h-4" }}
back to home
</a>
</div>
+173
appview/pages/templates/user/settings/notifications.html
···
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
+
+
{{ define "content" }}
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">Settings</p>
+
</div>
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
+
<div class="col-span-1">
+
{{ template "user/settings/fragments/sidebar" . }}
+
</div>
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
+
{{ template "notificationSettings" . }}
+
</div>
+
</section>
+
</div>
+
{{ end }}
+
+
{{ define "notificationSettings" }}
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
+
<div class="col-span-1 md:col-span-2">
+
<h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Choose which notifications you want to receive when activity happens on your repositories and profile.
+
</p>
+
</div>
+
</div>
+
+
<form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6">
+
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Repository starred</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone stars your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New issues</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone creates an issue on your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Issue comments</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone comments on an issue you're involved with.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Issue closed</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When an issue on your repository is closed.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New pull requests</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone creates a pull request on your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Pull request comments</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone comments on a pull request you're involved with.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Pull request merged</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When your pull request is merged.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New followers</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone follows you.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Email notifications</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>Receive notifications via email in addition to in-app notifications.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}>
+
</label>
+
</div>
+
</div>
+
+
<div class="flex justify-end pt-2">
+
<button
+
type="submit"
+
class="btn-create flex items-center gap-2 group"
+
>
+
{{ i "save" "w-4 h-4" }}
+
save
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</div>
+
<div id="settings-notifications-success"></div>
+
+
<div id="settings-notifications-error" class="error"></div>
+
</form>
+
{{ end }}
+13 -4
appview/pages/templates/layouts/fragments/topbar.html
···
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
<div class="flex justify-between p-0 items-center">
<div id="left-items">
-
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline">
-
{{ template "fragments/logotypeSmall" }}
+
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
+
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
+
alpha
+
</span>
</a>
</div>
···
{{ define "newButton" }}
<details class="relative inline-block text-left nav-dropdown">
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
-
{{ i "plus" "w-4 h-4" }} new
+
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
</summary>
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
<a href="/repo/new" class="flex items-center gap-2">
···
class="cursor-pointer list-none flex items-center gap-1"
>
{{ $user := didOrHandle .Did .Handle }}
-
{{ template "user/fragments/picHandle" $user }}
+
<img
+
src="{{ tinyAvatar $user }}"
+
alt=""
+
class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700"
+
/>
+
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
</summary>
<div
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+2 -2
appview/pages/templates/strings/put.html
···
{{ define "content" }}
<div class="px-6 py-2 mb-4">
{{ if eq .Action "new" }}
-
<p class="text-xl font-bold dark:text-white">Create a new string</p>
-
<p class="">Store and share code snippets with ease.</p>
+
<p class="text-xl font-bold dark:text-white mb-1">Create a new string</p>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p>
{{ else }}
<p class="text-xl font-bold dark:text-white">Edit string</p>
{{ end }}
-33
knotserver/git/git.go
···
return g.r.CommitObject(h)
}
-
func (g *GitRepo) LastCommit() (*object.Commit, error) {
-
c, err := g.r.CommitObject(g.h)
-
if err != nil {
-
return nil, fmt.Errorf("last commit: %w", err)
-
}
-
return c, nil
-
}
-
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
c, err := g.r.CommitObject(g.h)
if err != nil {
···
return buf.Bytes(), nil
}
-
func (g *GitRepo) FileContent(path string) (string, error) {
-
c, err := g.r.CommitObject(g.h)
-
if err != nil {
-
return "", fmt.Errorf("commit object: %w", err)
-
}
-
-
tree, err := c.Tree()
-
if err != nil {
-
return "", fmt.Errorf("file tree: %w", err)
-
}
-
-
file, err := tree.File(path)
-
if err != nil {
-
return "", err
-
}
-
-
isbin, _ := file.IsBinary()
-
-
if !isbin {
-
return file.Contents()
-
} else {
-
return "", ErrBinaryFile
-
}
-
}
-
func (g *GitRepo) RawContent(path string) ([]byte, error) {
c, err := g.r.CommitObject(g.h)
if err != nil {
-4
knotserver/http_util.go
···
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
-
-
func notFound(w http.ResponseWriter) {
-
writeError(w, "not found", http.StatusNotFound)
-
}
+1 -1
appview/ingester.go
···
if !ok {
return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs)))
-
if err := i.Validator.ValidateLabelOp(def, &o); err != nil {
+
if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil {
return fmt.Errorf("failed to validate labelop: %w", err)
+4 -1
appview/validator/validator.go
···
"tangled.org/core/appview/db"
"tangled.org/core/appview/pages/markup"
"tangled.org/core/idresolver"
+
"tangled.org/core/rbac"
)
type Validator struct {
db *db.DB
sanitizer markup.Sanitizer
resolver *idresolver.Resolver
+
enforcer *rbac.Enforcer
}
-
func New(db *db.DB, res *idresolver.Resolver) *Validator {
+
func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator {
return &Validator{
db: db,
sanitizer: markup.NewSanitizer(),
resolver: res,
+
enforcer: enforcer,
}
}
+5 -4
appview/models/label.go
···
}
var ops []LabelOp
-
for _, o := range record.Add {
+
// deletes first, then additions
+
for _, o := range record.Delete {
if o != nil {
op := mkOp(o)
-
op.Operation = LabelOperationAdd
+
op.Operation = LabelOperationDel
ops = append(ops, op)
}
}
-
for _, o := range record.Delete {
+
for _, o := range record.Add {
if o != nil {
op := mkOp(o)
-
op.Operation = LabelOperationDel
+
op.Operation = LabelOperationAdd
ops = append(ops, op)
}
}
+1 -3
knotserver/git/tag.go
···
import (
"fmt"
-
"slices"
"strconv"
"strings"
"time"
···
outFormat.WriteString("")
outFormat.WriteString(recordSeparator)
-
output, err := g.forEachRef(outFormat.String(), "refs/tags")
+
output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags")
if err != nil {
return nil, fmt.Errorf("failed to get tags: %w", err)
}
···
tags = append(tags, tag)
}
-
slices.Reverse(tags)
return tags, nil
}
+1 -1
knotserver/xrpc/repo_blob.go
···
contents, err := gr.RawContent(treePath)
if err != nil {
-
x.Logger.Error("file content", "error", err.Error())
+
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
writeError(w, xrpcerr.NewXrpcError(
xrpcerr.WithTag("FileNotFound"),
xrpcerr.WithMessage("file not found at the specified path"),
+10
api/tangled/repotree.go
···
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
// parent: The parent path in the tree
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
+
// readme: Readme for this file tree
+
Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"`
// ref: The git reference used
Ref string `json:"ref" cborgen:"ref"`
}
+
// RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema.
+
type RepoTree_Readme struct {
+
// contents: Contents of the readme file
+
Contents string `json:"contents" cborgen:"contents"`
+
// filename: Name of the readme file
+
Filename string `json:"filename" cborgen:"filename"`
+
}
+
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
type RepoTree_TreeEntry struct {
// is_file: Whether this entry is a file
+15 -17
appview/pages/markup/format.go
···
package markup
-
import "strings"
+
import (
+
"regexp"
+
)
type Format string
···
)
var FileTypes map[Format][]string = map[Format][]string{
-
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
+
FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
+
}
+
+
var FileTypePatterns = map[Format]*regexp.Regexp{
+
FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`),
}
-
// ReadmeFilenames contains the list of common README filenames to search for,
-
// in order of preference. Only includes well-supported formats.
-
var ReadmeFilenames = []string{
-
"README.md", "readme.md",
-
"README",
-
"readme",
-
"README.markdown",
-
"readme.markdown",
-
"README.txt",
-
"readme.txt",
+
var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`)
+
+
func IsReadmeFile(filename string) bool {
+
return ReadmePattern.MatchString(filename)
}
func GetFormat(filename string) Format {
-
for format, extensions := range FileTypes {
-
for _, extension := range extensions {
-
if strings.HasSuffix(filename, extension) {
-
return format
-
}
+
for format, pattern := range FileTypePatterns {
+
if pattern.MatchString(filename) {
+
return format
}
}
// default format
+2 -2
appview/pages/templates/repo/fragments/readme.html
···
{{ define "repo/fragments/readme" }}
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
{{- if .ReadmeFileName -}}
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
</div>
{{- end -}}
<section
-
class="p-6 overflow-auto {{ if not .Raw }}
+
class="px-6 pb-6 overflow-auto {{ if not .Raw }}
prose dark:prose-invert dark:[&_pre]:bg-gray-900
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
dark:[&_pre]:border dark:[&_pre]:border-gray-700
+24
knotserver/xrpc/repo_tree.go
···
"net/http"
"path/filepath"
"time"
+
"unicode/utf8"
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/pages/markup"
"tangled.org/core/knotserver/git"
xrpcerr "tangled.org/core/xrpc/errors"
)
···
return
}
+
// if any of these files are a readme candidate, pass along its blob contents too
+
var readmeFileName string
+
var readmeContents string
+
for _, file := range files {
+
if markup.IsReadmeFile(file.Name) {
+
contents, err := gr.RawContent(filepath.Join(path, file.Name))
+
if err != nil {
+
x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name)
+
}
+
+
if utf8.Valid(contents) {
+
readmeFileName = file.Name
+
readmeContents = string(contents)
+
break
+
}
+
}
+
}
+
// convert NiceTree -> tangled.RepoTree_TreeEntry
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
for i, file := range files {
···
Parent: parentPtr,
Dotdot: dotdotPtr,
Files: treeEntries,
+
Readme: &tangled.RepoTree_Readme{
+
Filename: readmeFileName,
+
Contents: readmeContents,
+
},
}
writeJson(w, response)
+19
lexicons/repo/tree.json
···
"type": "string",
"description": "Parent directory path"
},
+
"readme": {
+
"type": "ref",
+
"ref": "#readme",
+
"description": "Readme for this file tree"
+
},
"files": {
"type": "array",
"items": {
···
}
]
},
+
"readme": {
+
"type": "object",
+
"required": ["filename", "contents"],
+
"properties": {
+
"filename": {
+
"type": "string",
+
"description": "Name of the readme file"
+
},
+
"contents": {
+
"type": "string",
+
"description": "Contents of the readme file"
+
}
+
}
+
},
"treeEntry": {
"type": "object",
"required": ["name", "mode", "size", "is_file", "is_subtree"],
+7 -5
types/repo.go
···
}
type RepoTreeResponse struct {
-
Ref string `json:"ref,omitempty"`
-
Parent string `json:"parent,omitempty"`
-
Description string `json:"description,omitempty"`
-
DotDot string `json:"dotdot,omitempty"`
-
Files []NiceTree `json:"files,omitempty"`
+
Ref string `json:"ref,omitempty"`
+
Parent string `json:"parent,omitempty"`
+
Description string `json:"description,omitempty"`
+
DotDot string `json:"dotdot,omitempty"`
+
Files []NiceTree `json:"files,omitempty"`
+
ReadmeFileName string `json:"readme_filename,omitempty"`
+
Readme string `json:"readme_contents,omitempty"`
}
type TagReference struct {
+224
appview/pages/templates/brand/brand.html
···
+
{{ define "title" }}brand{{ end }}
+
+
{{ define "content" }}
+
<div class="grid grid-cols-10">
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
+
Assets and guidelines for using Tangled's logo and brand elements.
+
</p>
+
</header>
+
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
+
<div class="space-y-16">
+
+
<!-- Introduction Section -->
+
<section>
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
+
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
+
follow the below guidelines when using Dolly and the logotype.
+
</p>
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
+
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
+
</p>
+
</section>
+
+
<!-- Black Logotype Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
+
alt="Tangled logo - black version"
+
class="w-full max-w-sm mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
+
backgrounds and designs.
+
</p>
+
</div>
+
</section>
+
+
<!-- White Logotype Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="bg-black p-8 sm:p-16 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg"
+
alt="Tangled logo - white version"
+
class="w-full max-w-sm mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
This version features white text and elements, ideal for dark backgrounds
+
and inverted designs.
+
</p>
+
</div>
+
</section>
+
+
<!-- Mark Only Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Black mark on light background -->
+
<div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Dolly face - black version"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- White mark on dark background -->
+
<div class="bg-black p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Dolly face - white version"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
<strong class="font-semibold">Note</strong>: for situations where the background
+
is unknown, use the black version for ideal contrast in most environments.
+
</p>
+
</div>
+
</section>
+
+
<!-- Colored Backgrounds Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Pastel Green background -->
+
<div class="bg-green-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel green background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Blue background -->
+
<div class="bg-blue-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel blue background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Yellow background -->
+
<div class="bg-yellow-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel yellow background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Red background -->
+
<div class="bg-red-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel red background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
White logo mark on colored backgrounds.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
The white logo mark provides contrast on colored backgrounds.
+
Perfect for more fun design contexts.
+
</p>
+
</div>
+
</section>
+
+
<!-- Black Logo on Pastel Backgrounds Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Pastel Green background -->
+
<div class="bg-green-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel green background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Blue background -->
+
<div class="bg-blue-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel blue background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Yellow background -->
+
<div class="bg-yellow-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel yellow background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Pink background -->
+
<div class="bg-pink-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel pink background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
Dark logo mark on lighter, pastel backgrounds.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
The dark logo mark works beautifully on pastel backgrounds,
+
providing crisp contrast.
+
</p>
+
</div>
+
</section>
+
+
<!-- Recoloring Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
+
alt="Recolored Tangled logotype in gray/sand color"
+
class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
Custom coloring of the logotype is permitted.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
Recoloring the logotype is allowed as long as readability is maintained.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 text-sm">
+
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
+
</p>
+
</div>
+
</section>
+
+
<!-- Silhouette Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
+
alt="Dolly silhouette"
+
class="w-full max-w-32 mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
The silhouette can be used where a subtle brand presence is needed,
+
or as a background element. Works on any background color with proper contrast.
+
For example, we use this as the site's favicon.
+
</p>
+
</div>
+
</section>
+
+
</div>
+
</main>
+
</div>
+
{{ end }}
+13 -6
appview/pages/templates/legal/privacy.html
···
{{ define "title" }}privacy policy{{ end }}
{{ define "content" }}
-
<div class="max-w-4xl mx-auto px-4 py-8">
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
-
<div class="prose prose-gray dark:prose-invert max-w-none">
-
{{ .Content }}
-
</div>
+
<div class="grid grid-cols-10">
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
+
Learn how we collect, use, and protect your personal information.
+
</p>
+
</header>
+
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
+
<div class="prose prose-gray dark:prose-invert max-w-none">
+
{{ .Content }}
</div>
+
</main>
</div>
-
{{ end }}
+
{{ end }}
+13 -6
appview/pages/templates/legal/terms.html
···
{{ define "title" }}terms of service{{ end }}
{{ define "content" }}
-
<div class="max-w-4xl mx-auto px-4 py-8">
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
-
<div class="prose prose-gray dark:prose-invert max-w-none">
-
{{ .Content }}
-
</div>
+
<div class="grid grid-cols-10">
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
+
A few things you should know.
+
</p>
+
</header>
+
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
+
<div class="prose prose-gray dark:prose-invert max-w-none">
+
{{ .Content }}
</div>
+
</main>
</div>
-
{{ end }}
+
{{ end }}
+4 -2
appview/config/config.go
···
}
type Cloudflare struct {
-
ApiToken string `env:"API_TOKEN"`
-
ZoneId string `env:"ZONE_ID"`
+
ApiToken string `env:"API_TOKEN"`
+
ZoneId string `env:"ZONE_ID"`
+
TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"`
+
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
}
func (cfg RedisConfig) ToURL() string {
+7
appview/pages/templates/repo/fork.html
···
</div>
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
+
+
<fieldset class="space-y-3">
+
<legend for="repo_name" class="dark:text-white">Repository name</legend>
+
<input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}"
+
class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" />
+
</fieldset>
+
<fieldset class="space-y-3">
<legend class="dark:text-white">Select a knot to fork into</legend>
<div class="space-y-2">
+11 -7
appview/repo/repo.go
···
// choose a name for a fork
-
forkName := f.Name
+
forkName := r.FormValue("repo_name")
+
if forkName == "" {
+
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
+
return
+
}
+
// this check is *only* to see if the forked repo name already exists
// in the user's account.
existingRepo, err := db.GetRepo(
rp.db,
db.FilterEq("did", user.Did),
-
db.FilterEq("name", f.Name),
+
db.FilterEq("name", forkName),
if err != nil {
-
if errors.Is(err, sql.ErrNoRows) {
-
// no existing repo with this name found, we can use the name as is
-
} else {
+
if !errors.Is(err, sql.ErrNoRows) {
log.Println("error fetching existing repo from db", "err", err)
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
return
} else if existingRepo != nil {
-
// repo with this name already exists, append random string
-
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
+
// repo with this name already exists
+
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
+
return
l = l.With("forkName", forkName)
+140
appview/db/db.go
···
return err
})
+
// add generated at_uri column to pulls table
+
//
+
// this requires a full table recreation because stored columns
+
// cannot be added via alter
+
//
+
// disable foreign-keys for the next migration
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
+
runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table if not exists pulls_new (
+
-- identifiers
+
id integer primary key autoincrement,
+
pull_id integer not null,
+
at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
+
+
-- at identifiers
+
repo_at text not null,
+
owner_did text not null,
+
rkey text not null,
+
+
-- content
+
title text not null,
+
body text not null,
+
target_branch text not null,
+
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
+
+
-- source info
+
source_branch text,
+
source_repo_at text,
+
+
-- stacking
+
stack_id text,
+
change_id text,
+
parent_change_id text,
+
+
-- meta
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
-- constraints
+
unique(repo_at, pull_id),
+
unique(at_uri),
+
foreign key (repo_at) references repos(at_uri) on delete cascade
+
);
+
`)
+
if err != nil {
+
return err
+
}
+
+
// transfer data
+
_, err = tx.Exec(`
+
insert into pulls_new (
+
id, pull_id, repo_at, owner_did, rkey,
+
title, body, target_branch, state,
+
source_branch, source_repo_at,
+
stack_id, change_id, parent_change_id,
+
created
+
)
+
select
+
id, pull_id, repo_at, owner_did, rkey,
+
title, body, target_branch, state,
+
source_branch, source_repo_at,
+
stack_id, change_id, parent_change_id,
+
created
+
from pulls;
+
`)
+
if err != nil {
+
return err
+
}
+
+
// drop old table
+
_, err = tx.Exec(`drop table pulls`)
+
if err != nil {
+
return err
+
}
+
+
// rename new table
+
_, err = tx.Exec(`alter table pulls_new rename to pulls`)
+
return err
+
})
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
+
+
// remove repo_at and pull_id from pull_submissions and replace with pull_at
+
//
+
// this requires a full table recreation because stored columns
+
// cannot be added via alter
+
//
+
// disable foreign-keys for the next migration
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
+
runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table if not exists pull_submissions_new (
+
-- identifiers
+
id integer primary key autoincrement,
+
pull_at text not null,
+
+
-- content, these are immutable, and require a resubmission to update
+
round_number integer not null default 0,
+
patch text,
+
source_rev text,
+
+
-- meta
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
-- constraints
+
unique(pull_at, round_number),
+
foreign key (pull_at) references pulls(at_uri) on delete cascade
+
);
+
`)
+
if err != nil {
+
return err
+
}
+
+
// transfer data, constructing pull_at from pulls table
+
_, err = tx.Exec(`
+
insert into pull_submissions_new (id, pull_at, round_number, patch, created)
+
select
+
ps.id,
+
'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
+
ps.round_number,
+
ps.patch,
+
ps.created
+
from pull_submissions ps
+
join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
+
`)
+
if err != nil {
+
return err
+
}
+
+
// drop old table
+
_, err = tx.Exec(`drop table pull_submissions`)
+
if err != nil {
+
return err
+
}
+
+
// rename new table
+
_, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
+
return err
+
})
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
+
return &DB{db}, nil
}
+44 -1
appview/models/pull.go
···
PullSource *PullSource
// optionally, populate this when querying for reverse mappings
-
Repo *Repo
+
Labels LabelState
+
Repo *Repo
}
func (p Pull) AsRecord() tangled.RepoPull {
···
return p.StackId != ""
}
+
func (p *Pull) Participants() []string {
+
participantSet := make(map[string]struct{})
+
participants := []string{}
+
+
addParticipant := func(did string) {
+
if _, exists := participantSet[did]; !exists {
+
participantSet[did] = struct{}{}
+
participants = append(participants, did)
+
}
+
}
+
+
addParticipant(p.OwnerDid)
+
+
for _, s := range p.Submissions {
+
for _, sp := range s.Participants() {
+
addParticipant(sp)
+
}
+
}
+
+
return participants
+
}
+
func (s PullSubmission) IsFormatPatch() bool {
return patchutil.IsFormatPatch(s.Patch)
}
···
return patches
}
+
func (s *PullSubmission) Participants() []string {
+
participantSet := make(map[string]struct{})
+
participants := []string{}
+
+
addParticipant := func(did string) {
+
if _, exists := participantSet[did]; !exists {
+
participantSet[did] = struct{}{}
+
participants = append(participants, did)
+
}
+
}
+
+
addParticipant(s.PullAt.Authority().String())
+
+
for _, c := range s.Comments {
+
addParticipant(c.OwnerDid)
+
}
+
+
return participants
+
}
+
type Stack []*Pull
// position of this pull in the stack
+26
appview/pages/templates/repo/fragments/participants.html
···
+
{{ define "repo/fragments/participants" }}
+
{{ $all := . }}
+
{{ $ps := take $all 5 }}
+
<div class="px-6 md:px-0">
+
<div class="py-1 flex items-center text-sm">
+
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
+
</div>
+
<div class="flex items-center -space-x-3 mt-2">
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
+
{{ range $i, $p := $ps }}
+
<img
+
src="{{ tinyAvatar . }}"
+
alt=""
+
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
+
/>
+
{{ end }}
+
+
{{ if gt (len $all) 5 }}
+
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
+
+{{ sub (len $all) 5 }}
+
</span>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+30 -12
appview/pages/templates/repo/pulls/pull.html
···
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
+
{{ define "repoContentLayout" }}
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
+
<div class="col-span-1 md:col-span-8">
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
+
{{ block "repoContent" . }}{{ end }}
+
</section>
+
{{ block "repoAfter" . }}{{ end }}
+
</div>
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
+
{{ template "repo/fragments/labelPanel"
+
(dict "RepoInfo" $.RepoInfo
+
"Defs" $.LabelDefs
+
"Subject" $.Pull.PullAt
+
"State" $.Pull.Labels) }}
+
{{ template "repo/fragments/participants" $.Pull.Participants }}
+
</div>
+
</div>
+
{{ end }}
{{ define "repoContent" }}
{{ template "repo/pulls/fragments/pullHeader" . }}
···
{{ with $item }}
<details {{ if eq $idx $lastIdx }}open{{ end }}>
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
-
<div class="flex flex-wrap gap-2 items-center">
+
<div class="flex flex-wrap gap-2 items-stretch">
<!-- round number -->
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
</div>
<!-- round summary -->
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
<span class="gap-1 flex items-center">
{{ $owner := resolve $.Pull.OwnerDid }}
{{ $re := "re" }}
···
<span class="hidden md:inline">diff</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</a>
-
{{ if not (eq .RoundNumber 0) }}
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
-
hx-boost="true"
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
-
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
-
<span class="hidden md:inline">interdiff</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</a>
-
<span id="interdiff-error-{{.RoundNumber}}"></span>
+
{{ if ne $idx 0 }}
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
+
hx-boost="true"
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
+
<span class="hidden md:inline">interdiff</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
{{ end }}
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
</div>
</summary>
···
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
{{ range $cidx, $c := .Comments }}
-
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
{{ if gt $cidx 0 }}
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
{{ end }}
+7
appview/pages/templates/repo/pulls/pulls.html
···
<span class="before:content-['ยท']"></span>
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
{{ end }}
+
+
{{ $state := .Labels }}
+
{{ range $k, $d := $.LabelDefs }}
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
+
{{ end }}
+
{{ end }}
</div>
</div>
{{ if .StackId }}
+8 -48
appview/notify/db/db.go
···
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
var err error
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(star.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
if err != nil {
log.Printf("NewStar: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewStar: no repo found for %s", star.RepoAt)
-
return
-
}
-
repo := repos[0]
// don't notify yourself
if repo.Did == star.StarredByDid {
···
}
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssue: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewIssue: no repo found for %s", issue.RepoAt)
-
return
-
}
-
repo := repos[0]
if repo.Did == issue.Did {
return
···
}
issue := issues[0]
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssueComment: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewIssueComment: no repo found for %s", issue.RepoAt)
-
return
-
}
-
repo := repos[0]
recipients := make(map[string]bool)
···
}
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPull: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPull: no repo found for %s", pull.RepoAt)
-
return
-
}
-
repo := repos[0]
if repo.Did == pull.OwnerDid {
return
···
}
pull := pulls[0]
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", comment.RepoAt))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
if err != nil {
log.Printf("NewPullComment: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPullComment: no repo found for %s", comment.RepoAt)
-
return
-
}
-
repo := repos[0]
recipients := make(map[string]bool)
···
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
// Get repo details
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssueClosed: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewIssueClosed: no repo found for %s", issue.RepoAt)
-
return
-
}
-
repo := repos[0]
// Don't notify yourself
if repo.Did == issue.Did {
···
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
// Get repo details
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPullMerged: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPullMerged: no repo found for %s", pull.RepoAt)
-
return
-
}
-
repo := repos[0]
// Don't notify yourself
if repo.Did == pull.OwnerDid {
···
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
// Get repo details
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPullClosed: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPullClosed: no repo found for %s", pull.RepoAt)
-
return
-
}
-
repo := repos[0]
// Don't notify yourself
if repo.Did == pull.OwnerDid {
+18 -13
appview/db/notifications.go
···
import (
"context"
"database/sql"
+
"errors"
"fmt"
+
"strings"
"time"
"tangled.org/core/appview/models"
···
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
}
-
func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) {
-
recipientFilter := FilterEq("recipient_did", userDID)
-
readFilter := FilterEq("read", 0)
+
func CountNotifications(e Execer, filters ...filter) (int64, error) {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
-
query := fmt.Sprintf(`
-
SELECT COUNT(*)
-
FROM notifications
-
WHERE %s AND %s
-
`, recipientFilter.Condition(), readFilter.Condition())
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
args := append(recipientFilter.Arg(), readFilter.Arg()...)
+
query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause)
+
var count int64
+
err := e.QueryRow(query, args...).Scan(&count)
-
var count int
-
err := d.DB.QueryRowContext(ctx, query, args...).Scan(&count)
-
if err != nil {
-
return 0, fmt.Errorf("failed to get unread count: %w", err)
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
+
return 0, err
}
return count, nil
+29 -36
appview/notifications/notifications.go
···
package notifications
import (
+
"fmt"
"log"
"net/http"
"strconv"
···
r.Use(middleware.AuthMiddleware(n.oauth))
-
r.Get("/", n.notificationsPage)
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
r.Get("/count", n.getUnreadCount)
r.Post("/{id}/read", n.markRead)
···
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
userDid := n.oauth.GetDid(r)
-
limitStr := r.URL.Query().Get("limit")
-
offsetStr := r.URL.Query().Get("offset")
-
-
limit := 20 // default
-
if limitStr != "" {
-
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
-
limit = l
-
}
+
page, ok := r.Context().Value("page").(pagination.Page)
+
if !ok {
+
log.Println("failed to get page")
+
page = pagination.FirstPage()
}
-
offset := 0 // default
-
if offsetStr != "" {
-
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
-
offset = o
-
}
+
total, err := db.CountNotifications(
+
n.db,
+
db.FilterEq("recipient_did", userDid),
+
)
+
if err != nil {
+
log.Println("failed to get total notifications:", err)
+
n.pages.Error500(w)
+
return
}
-
page := pagination.Page{Limit: limit + 1, Offset: offset}
-
notifications, err := db.GetNotificationsWithEntities(n.db, page, db.FilterEq("recipient_did", userDid))
+
notifications, err := db.GetNotificationsWithEntities(
+
n.db,
+
page,
+
db.FilterEq("recipient_did", userDid),
+
)
if err != nil {
log.Println("failed to get notifications:", err)
n.pages.Error500(w)
return
}
-
hasMore := len(notifications) > limit
-
if hasMore {
-
notifications = notifications[:limit]
-
}
-
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
if err != nil {
log.Println("failed to mark notifications as read:", err)
···
return
}
-
params := pages.NotificationsParams{
+
fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{
LoggedInUser: user,
Notifications: notifications,
UnreadCount: unreadCount,
-
HasMore: hasMore,
-
NextOffset: offset + limit,
-
Limit: limit,
-
}
-
-
err = n.pages.Notifications(w, params)
-
if err != nil {
-
log.Println("failed to load notifs:", err)
-
n.pages.Error500(w)
-
return
-
}
+
Page: page,
+
Total: total,
+
}))
}
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
-
userDid := n.oauth.GetDid(r)
-
-
count, err := n.db.GetUnreadNotificationCount(r.Context(), userDid)
+
user := n.oauth.GetUser(r)
+
count, err := db.CountNotifications(
+
n.db,
+
db.FilterEq("recipient_did", user.Did),
+
db.FilterEq("read", 0),
+
)
if err != nil {
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
return
+48 -15
appview/pages/templates/notifications/list.html
···
</div>
</div>
-
{{if .Notifications}}
-
<div class="flex flex-col gap-2" id="notifications-list">
-
{{range .Notifications}}
-
{{template "notifications/fragments/item" .}}
-
{{end}}
-
</div>
+
{{if .Notifications}}
+
<div class="flex flex-col gap-2" id="notifications-list">
+
{{range .Notifications}}
+
{{template "notifications/fragments/item" .}}
+
{{end}}
+
</div>
-
{{else}}
-
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
-
<div class="text-center py-12">
-
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
-
{{ i "bell-off" "w-16 h-16" }}
-
</div>
-
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
-
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
+
{{else}}
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<div class="text-center py-12">
+
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
+
{{ i "bell-off" "w-16 h-16" }}
</div>
+
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
+
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
</div>
-
{{end}}
+
</div>
+
{{end}}
+
+
{{ template "pagination" . }}
+
{{ end }}
+
+
{{ define "pagination" }}
+
<div class="flex justify-end mt-4 gap-2">
+
{{ if gt .Page.Offset 0 }}
+
{{ $prev := .Page.Previous }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
+
>
+
{{ i "chevron-left" "w-4 h-4" }}
+
previous
+
</a>
+
{{ else }}
+
<div></div>
+
{{ end }}
+
+
{{ $next := .Page.Next }}
+
{{ if lt $next.Offset .Total }}
+
{{ $next := .Page.Next }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
+
>
+
next
+
{{ i "chevron-right" "w-4 h-4" }}
+
</a>
+
{{ end }}
+
</div>
{{ end }}
+1 -1
appview/pagination/page.go
···
func FirstPage() Page {
return Page{
Offset: 0,
-
Limit: 10,
+
Limit: 30,
}
}
+27 -208
appview/pages/templates/notifications/fragments/item.html
···
{{define "notifications/fragments/item"}}
-
<div
-
class="
-
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
-
{{if not .Read}}bg-blue-50 dark:bg-blue-900/20 border border-blue-500 dark:border-sky-800{{end}}
-
flex gap-2 items-center
-
"
-
>
+
<a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline">
+
<div
+
class="
+
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
+
{{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}}
+
flex gap-2 items-center
+
">
+
{{ template "notificationIcon" . }}
+
<div class="flex-1 w-full flex flex-col gap-1">
+
<span>{{ template "notificationHeader" . }}</span>
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
+
</div>
-
{{ template "notificationIcon" . }}
-
<div class="flex-1 w-full flex flex-col gap-1">
-
<span>{{ template "notificationHeader" . }}</span>
-
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
</div>
-
-
</div>
+
</a>
{{end}}
{{ define "notificationIcon" }}
···
{{ end }}
{{ end }}
-
{{define "issueNotification"}}
-
{{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="min-w-0 flex-1">
-
<!-- First line: icon + actor action -->
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
-
{{if eq .Type "issue_created"}}
-
<span class="text-green-600 dark:text-green-500">
-
{{ i "circle-dot" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "issue_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ i "message-circle" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "issue_closed"}}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ i "ban" "w-4 h-4" }}
-
</span>
-
{{end}}
-
{{template "user/fragments/picHandle" .ActorDid}}
-
{{if eq .Type "issue_created"}}
-
<span class="text-gray-500 dark:text-gray-400">opened issue</span>
-
{{else if eq .Type "issue_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">commented on issue</span>
-
{{else if eq .Type "issue_closed"}}
-
<span class="text-gray-500 dark:text-gray-400">closed issue</span>
-
{{end}}
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
-
<span class="text-gray-500 dark:text-gray-400">#{{.Issue.IssueId}}</span>
-
<span class="text-gray-900 dark:text-white truncate">{{.Issue.Title}}</span>
-
<span>on</span>
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "pullNotification"}}
-
{{$url := printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="min-w-0 flex-1">
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
-
{{if eq .Type "pull_created"}}
-
<span class="text-green-600 dark:text-green-500">
-
{{ i "git-pull-request-create" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "pull_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ i "message-circle" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "pull_merged"}}
-
<span class="text-purple-600 dark:text-purple-500">
-
{{ i "git-merge" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "pull_closed"}}
-
<span class="text-red-600 dark:text-red-500">
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
-
</span>
-
{{end}}
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
-
{{if eq .Type "pull_created"}}
-
<span class="text-gray-500 dark:text-gray-400">opened pull request</span>
-
{{else if eq .Type "pull_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">commented on pull request</span>
-
{{else if eq .Type "pull_merged"}}
-
<span class="text-gray-500 dark:text-gray-400">merged pull request</span>
-
{{else if eq .Type "pull_closed"}}
-
<span class="text-gray-500 dark:text-gray-400">closed pull request</span>
-
{{end}}
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
-
<span class="text-gray-500 dark:text-gray-400">#{{.Pull.PullId}}</span>
-
<span class="text-gray-900 dark:text-white truncate">{{.Pull.Title}}</span>
-
<span>on</span>
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "repoNotification"}}
-
{{$url := printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="flex items-center gap-2 min-w-0 flex-1">
-
<span class="text-yellow-500 dark:text-yellow-400">
-
{{ i "star" "w-4 h-4" }}
-
</span>
-
-
<div class="min-w-0 flex-1">
-
<!-- Single line for stars: actor action subject -->
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
-
<span class="text-gray-500 dark:text-gray-400">starred</span>
-
<span class="font-medium">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "followNotification"}}
-
{{$url := printf "/%s" (resolve .ActorDid)}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="flex items-center gap-2 min-w-0 flex-1">
-
<span class="text-blue-600 dark:text-blue-400">
-
{{ i "user-plus" "w-4 h-4" }}
-
</span>
-
-
<div class="min-w-0 flex-1">
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
-
<span class="text-gray-500 dark:text-gray-400">followed you</span>
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "genericNotification"}}
-
<a
-
href="#"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="flex items-center gap-2 min-w-0 flex-1">
-
<span class="{{if not .Read}}text-blue-600 dark:text-blue-400{{else}}text-gray-500 dark:text-gray-400{{end}}">
-
{{ i "bell" "w-4 h-4" }}
-
</span>
-
-
<div class="min-w-0 flex-1">
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
-
<span>New notification</span>
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
</div>
-
</div>
+
{{ define "notificationUrl" }}
+
{{ $url := "" }}
+
{{ if eq .Type "repo_starred" }}
+
{{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
+
{{ else if .Issue }}
+
{{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
+
{{ else if .Pull }}
+
{{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
+
{{ else if eq .Type "followed" }}
+
{{$url = printf "/%s" (resolve .ActorDid)}}
+
{{ else }}
+
{{ end }}
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
+
{{ $url }}
+
{{ end }}
+1 -1
nix/pkgs/knot-unwrapped.nix
···
sqlite-lib,
src,
}: let
-
version = "1.9.0-alpha";
+
version = "1.9.1-alpha";
in
buildGoApplication {
pname = "knot";
+2 -2
appview/db/pulls.go
···
// collect pull source for all pulls that need it
var sourceAts []syntax.ATURI
for _, p := range pulls {
-
if p.PullSource.RepoAt != nil {
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
}
}
···
sourceRepoMap[r.RepoAt()] = &r
}
for _, p := range pulls {
-
if p.PullSource.RepoAt != nil {
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
p.PullSource.Repo = sourceRepo
}
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
···
{{ define "repo/fragments/cloneDropdown" }}
{{ $knot := .RepoInfo.Knot }}
{{ if eq $knot "knot1.tangled.sh" }}
-
{{ $knot = "tangled.sh" }}
+
{{ $knot = "tangled.org" }}
{{ end }}
<details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
docs/spindle/pipeline.md
···
- `manual`: The workflow can be triggered manually.
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
-
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
+
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
```yaml
when:
+1 -1
knotserver/config/config.go
···
Repo Repo `env:",prefix=KNOT_REPO_"`
Server Server `env:",prefix=KNOT_SERVER_"`
Git Git `env:",prefix=KNOT_GIT_"`
-
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
+
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"`
}
func Load(ctx context.Context) (*Config, error) {
+3
appview/pages/templates/layouts/base.html
···
<link rel="preconnect" href="https://avatar.tangled.sh" />
<link rel="preconnect" href="https://camo.tangled.sh" />
+
<!-- pwa manifest -->
+
<link rel="manifest" href="/pwa-manifest.json" />
+
<!-- preload main font -->
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
+1
appview/pages/templates/user/completeSignup.html
···
content="complete your signup for tangled"
/>
<script src="/static/htmx.min.js"></script>
+
<link rel="manifest" href="/pwa-manifest.json" />
<link
rel="stylesheet"
href="/static/tw.css?{{ cssContentHash }}"
+1
appview/pages/templates/user/signup.html
···
<meta property="og:url" content="https://tangled.org/signup" />
<meta property="og:description" content="sign up for tangled" />
<script src="/static/htmx.min.js"></script>
+
<link rel="manifest" href="/pwa-manifest.json" />
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>sign up &middot; tangled</title>
+12 -1
appview/repo/index.go
···
})
}
+
tx, err := rp.db.Begin()
+
if err != nil {
+
return nil, err
+
}
+
defer tx.Rollback()
+
// update appview's cache
-
err = db.InsertRepoLanguages(rp.db, langs)
+
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
if err != nil {
// non-fatal
log.Println("failed to cache lang results", err)
}
+
+
err = tx.Commit()
+
if err != nil {
+
return nil, err
+
}
}
var total int64