Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7888788d42 | |||
| 7a12cba4d5 | |||
| 78791175a1 | |||
| 2a1644e571 | |||
| bc5f1679d5 | |||
| 22634aa0c9 | |||
| 699e525cb9 | |||
| 2b2e5c666a | |||
| 26a8fb5c51 |
@@ -0,0 +1,18 @@
|
|||||||
|
"""Add 'cancelled' value to task_status enum."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0031"
|
||||||
|
down_revision = "0030"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TYPE task_status ADD VALUE IF NOT EXISTS 'cancelled'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# PostgreSQL does not support removing enum values without a full type rebuild.
|
||||||
|
# Downgrade is a no-op; the value simply becomes unused.
|
||||||
|
pass
|
||||||
@@ -580,3 +580,21 @@ export async function updateEvent(id: number, payload: EventUpdatePayload): Prom
|
|||||||
export async function deleteEvent(id: number): Promise<void> {
|
export async function deleteEvent(id: number): Promise<void> {
|
||||||
return apiDelete(`/api/events/${id}`);
|
return apiDelete(`/api/events/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ApiKeyEntry {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
scope: string
|
||||||
|
key_prefix: string
|
||||||
|
last_used_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listApiKeys = () =>
|
||||||
|
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
|
||||||
|
|
||||||
|
export const createApiKey = (name: string, scope: 'read' | 'write') =>
|
||||||
|
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
|
||||||
|
|
||||||
|
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `refreshFn` on a recurring interval while the page is visible.
|
||||||
|
* Safe to use in any component — timer is cleared on unmount.
|
||||||
|
*
|
||||||
|
* @param refreshFn Called each tick. Should be silent (no loading state changes).
|
||||||
|
* @param intervalMs Polling interval in milliseconds.
|
||||||
|
* @param canRun Optional guard — refresh is skipped when this returns false.
|
||||||
|
*/
|
||||||
|
export function useBackgroundRefresh(
|
||||||
|
refreshFn: () => void,
|
||||||
|
intervalMs: number,
|
||||||
|
canRun?: () => boolean,
|
||||||
|
): void {
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
if (document.hidden) return
|
||||||
|
if (canRun && !canRun()) return
|
||||||
|
refreshFn()
|
||||||
|
}, intervalMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer !== null) clearInterval(timer)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** Cyclic color palette for milestone progress bars. */
|
||||||
|
export const MILESTONE_PALETTE = [
|
||||||
|
'var(--color-primary)',
|
||||||
|
'var(--color-success, #22c55e)',
|
||||||
|
'#c98a00',
|
||||||
|
'#8b5cf6',
|
||||||
|
'var(--color-danger, #ef4444)',
|
||||||
|
'#06b6d4',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function milestoneColor(index: number): string {
|
||||||
|
return MILESTONE_PALETTE[index % MILESTONE_PALETTE.length]
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import ChatMessage from '@/components/ChatMessage.vue'
|
import ChatMessage from '@/components/ChatMessage.vue'
|
||||||
import WeatherCard from '@/components/WeatherCard.vue'
|
import WeatherCard from '@/components/WeatherCard.vue'
|
||||||
@@ -17,6 +18,15 @@ import {
|
|||||||
} from '@/api/client'
|
} from '@/api/client'
|
||||||
import type { Message } from '@/types/chat'
|
import type { Message } from '@/types/chat'
|
||||||
|
|
||||||
|
interface RssItemMeta {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
source: string
|
||||||
|
snippet: string
|
||||||
|
published_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface MessageMetadata {
|
interface MessageMetadata {
|
||||||
weather?: {
|
weather?: {
|
||||||
location: string
|
location: string
|
||||||
@@ -30,6 +40,7 @@ interface MessageMetadata {
|
|||||||
forecast: { day: string; condition: string; high: number; low: number }[]
|
forecast: { day: string; condition: string; high: number; low: number }[]
|
||||||
} | null
|
} | null
|
||||||
rss_item_ids?: number[]
|
rss_item_ids?: number[]
|
||||||
|
rss_items?: RssItemMeta[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
@@ -153,6 +164,16 @@ function msgMetadata(msg: BriefingMessage): MessageMetadata | null {
|
|||||||
return (msg.metadata as MessageMetadata | null | undefined) ?? null
|
return (msg.metadata as MessageMetadata | null | undefined) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRelativeDate(iso: string | null): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
const now = new Date()
|
||||||
|
const diffH = (now.getTime() - d.getTime()) / 3_600_000
|
||||||
|
if (diffH < 24) return `${Math.round(diffH)}h ago`
|
||||||
|
if (diffH < 48) return 'Yesterday'
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
// Manual trigger
|
// Manual trigger
|
||||||
const triggering = ref(false)
|
const triggering = ref(false)
|
||||||
async function triggerNow() {
|
async function triggerNow() {
|
||||||
@@ -190,10 +211,7 @@ function toMsg(m: BriefingMessage): Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
||||||
let _refreshTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
async function _backgroundRefreshMessages() {
|
async function _backgroundRefreshMessages() {
|
||||||
if (document.hidden || chatStore.streaming || !isToday.value || !todayConvId.value) return
|
|
||||||
try {
|
try {
|
||||||
const today = await getBriefingToday()
|
const today = await getBriefingToday()
|
||||||
if (!today) return
|
if (!today) return
|
||||||
@@ -206,14 +224,15 @@ async function _backgroundRefreshMessages() {
|
|||||||
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useBackgroundRefresh(
|
||||||
|
_backgroundRefreshMessages,
|
||||||
|
60_000,
|
||||||
|
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await checkSetup()
|
await checkSetup()
|
||||||
if (!showWizard.value) await loadAll()
|
if (!showWizard.value) await loadAll()
|
||||||
_refreshTimer = setInterval(_backgroundRefreshMessages, 60_000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (_refreshTimer !== null) clearInterval(_refreshTimer)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -263,29 +282,43 @@ onUnmounted(() => {
|
|||||||
:message="toMsg(msg)"
|
:message="toMsg(msg)"
|
||||||
:is-streaming="false"
|
:is-streaming="false"
|
||||||
/>
|
/>
|
||||||
<!-- Reaction buttons below assistant messages with rss_item_ids -->
|
<!-- News cards below assistant messages with rss_items -->
|
||||||
<div
|
<div
|
||||||
v-if="msg.role === 'assistant' && (msgMetadata(msg)?.rss_item_ids?.length ?? 0) > 0"
|
v-if="msg.role === 'assistant' && (msgMetadata(msg)?.rss_items?.length ?? 0) > 0"
|
||||||
class="rss-reactions"
|
class="news-cards"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(itemId, index) in msgMetadata(msg)!.rss_item_ids"
|
v-for="item in msgMetadata(msg)!.rss_items"
|
||||||
:key="itemId"
|
:key="item.id"
|
||||||
class="rss-reaction-row"
|
class="news-card"
|
||||||
>
|
>
|
||||||
<span class="reaction-label">Story {{ index + 1 }}</span>
|
<div class="news-card-meta">
|
||||||
<button
|
<span class="news-source">{{ item.source }}</span>
|
||||||
class="reaction-btn"
|
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
|
||||||
:class="{ active: reactions[itemId] === 'up' }"
|
</div>
|
||||||
@click="handleReaction(itemId, 'up')"
|
<a
|
||||||
title="Interested"
|
v-if="item.url"
|
||||||
>👍</button>
|
:href="item.url"
|
||||||
<button
|
target="_blank"
|
||||||
class="reaction-btn"
|
rel="noopener noreferrer"
|
||||||
:class="{ active: reactions[itemId] === 'down' }"
|
class="news-title"
|
||||||
@click="handleReaction(itemId, 'down')"
|
>{{ item.title }}</a>
|
||||||
title="Not interested"
|
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
|
||||||
>👎</button>
|
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
||||||
|
<div class="news-reactions">
|
||||||
|
<button
|
||||||
|
class="reaction-btn"
|
||||||
|
:class="{ active: reactions[item.id] === 'up' }"
|
||||||
|
@click="handleReaction(item.id, 'up')"
|
||||||
|
title="Interested"
|
||||||
|
>👍</button>
|
||||||
|
<button
|
||||||
|
class="reaction-btn"
|
||||||
|
:class="{ active: reactions[item.id] === 'down' }"
|
||||||
|
@click="handleReaction(item.id, 'down')"
|
||||||
|
title="Not interested"
|
||||||
|
>👎</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -484,33 +517,78 @@ onUnmounted(() => {
|
|||||||
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
.btn-send:hover:not(:disabled) { opacity: 0.9; }
|
.btn-send:hover:not(:disabled) { opacity: 0.9; }
|
||||||
|
|
||||||
.rss-reactions {
|
.news-cards {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
margin-top: 0.25rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rss-reaction-row {
|
.news-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-label {
|
.news-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-source {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-date {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.35;
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
||||||
|
|
||||||
|
.news-snippet {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
min-width: 4rem;
|
line-height: 1.45;
|
||||||
|
margin: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-reactions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reaction-btn {
|
.reaction-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.1rem 0.35rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.82rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
transition: opacity 0.15s, border-color 0.15s;
|
transition: opacity 0.15s, border-color 0.15s;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
import { apiGet, listEvents } from "@/api/client";
|
import { apiGet, listEvents } from "@/api/client";
|
||||||
|
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh";
|
||||||
|
import { milestoneColor } from "@/utils/palette";
|
||||||
import type { Note } from "@/types/note";
|
import type { Note } from "@/types/note";
|
||||||
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
|
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
|
||||||
import type { ToolCallRecord, Message } from "@/types/chat";
|
import type { ToolCallRecord, Message } from "@/types/chat";
|
||||||
@@ -69,24 +71,8 @@ const upcomingEvents = ref<EventEntry[]>([]);
|
|||||||
const eventSlideOverOpen = ref(false);
|
const eventSlideOverOpen = ref(false);
|
||||||
const editingEvent = ref<EventEntry | null>(null);
|
const editingEvent = ref<EventEntry | null>(null);
|
||||||
|
|
||||||
// ─── Milestone color palette ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function milestoneColor(index: number): string {
|
|
||||||
const palette = [
|
|
||||||
"var(--color-primary)",
|
|
||||||
"var(--color-success, #22c55e)",
|
|
||||||
"#c98a00",
|
|
||||||
"var(--color-danger, #e74c3c)",
|
|
||||||
"#8b5cf6",
|
|
||||||
];
|
|
||||||
return palette[index % palette.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
||||||
// Runs on a timer while the page is visible. Never touches `loading` so
|
// Never touches `loading` so existing content stays on screen while fetching.
|
||||||
// existing content stays on screen while the fetch is in flight.
|
|
||||||
|
|
||||||
let _refreshTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
function _dateRange() {
|
function _dateRange() {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
@@ -173,9 +159,10 @@ onMounted(async () => {
|
|||||||
chatInputRef.value?.focus();
|
chatInputRef.value?.focus();
|
||||||
loadProjects();
|
loadProjects();
|
||||||
|
|
||||||
_refreshTimer = setInterval(_backgroundRefresh, 90_000)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useBackgroundRefresh(_backgroundRefresh, 90_000, () => !loading.value);
|
||||||
|
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
if (!heroProject.value) return;
|
if (!heroProject.value) return;
|
||||||
const hid = heroProject.value.id;
|
const hid = heroProject.value.id;
|
||||||
@@ -239,7 +226,6 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener("shortcut:focus-chat", onFocusChatShortcut);
|
document.removeEventListener("shortcut:focus-chat", onFocusChatShortcut);
|
||||||
if (_refreshTimer !== null) clearInterval(_refreshTimer)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet, apiPost } from "@/api/client";
|
import { apiGet, apiPost } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
import { milestoneColor } from "@/utils/palette";
|
||||||
|
|
||||||
interface MilestoneSummary {
|
interface MilestoneSummary {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -73,17 +74,6 @@ async function loadProjects() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function milestoneColor(index: number): string {
|
|
||||||
const palette = [
|
|
||||||
"var(--color-primary)",
|
|
||||||
"var(--color-success)",
|
|
||||||
"#c98a00",
|
|
||||||
"#8b5cf6",
|
|
||||||
"#ef4444",
|
|
||||||
"#06b6d4",
|
|
||||||
];
|
|
||||||
return palette[index % palette.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadProjects);
|
onMounted(loadProjects);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ref, watch, onMounted } from "vue";
|
|||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
|
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
|
||||||
import { usePushStore } from "@/stores/push";
|
import { usePushStore } from "@/stores/push";
|
||||||
import type { User } from "@/types/auth";
|
import type { User } from "@/types/auth";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
@@ -43,17 +43,24 @@ const restoreFileInput = ref<HTMLInputElement | null>(null);
|
|||||||
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]);
|
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]);
|
||||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||||
|
|
||||||
|
function _loadTabContent(tab: string) {
|
||||||
|
if (authStore.isAdmin) {
|
||||||
|
if (tab === "users") loadUsersPanel();
|
||||||
|
else if (tab === "logs") loadLogsPanel();
|
||||||
|
else if (tab === "groups") loadGroupsPanel();
|
||||||
|
}
|
||||||
|
if (tab === "briefing") loadBriefingTab();
|
||||||
|
if (tab === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
|
||||||
|
}
|
||||||
|
|
||||||
watch(activeTab, (v) => {
|
watch(activeTab, (v) => {
|
||||||
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
|
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
|
||||||
if (v === "users" && authStore.isAdmin) loadUsersPanel();
|
_loadTabContent(v);
|
||||||
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
|
|
||||||
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
|
|
||||||
if (v === "briefing") loadBriefingTab();
|
|
||||||
if (v === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// API Keys
|
// API Keys
|
||||||
const apiKeys = ref<Array<{id: number, name: string, scope: string, key_prefix: string, last_used_at: string | null}>>([]);
|
const apiKeys = ref<ApiKeyEntry[]>([]);
|
||||||
const newKeyName = ref('');
|
const newKeyName = ref('');
|
||||||
const newKeyScope = ref<'read' | 'write'>('write');
|
const newKeyScope = ref<'read' | 'write'>('write');
|
||||||
const newKeyValue = ref('');
|
const newKeyValue = ref('');
|
||||||
@@ -89,36 +96,24 @@ async function loadMcpInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchApiKeys() {
|
async function fetchApiKeys() {
|
||||||
const res = await fetch('/api/api-keys', { credentials: 'include' });
|
apiKeys.value = await listApiKeys();
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
apiKeys.value = data.api_keys;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createApiKey() {
|
async function createApiKey() {
|
||||||
if (!newKeyName.value) return;
|
if (!newKeyName.value) return;
|
||||||
creatingApiKey.value = true;
|
creatingApiKey.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/api-keys', {
|
const data = await apiCreateApiKey(newKeyName.value, newKeyScope.value);
|
||||||
method: 'POST',
|
newKeyValue.value = data.key;
|
||||||
credentials: 'include',
|
newKeyName.value = '';
|
||||||
headers: { 'Content-Type': 'application/json' },
|
await fetchApiKeys();
|
||||||
body: JSON.stringify({ name: newKeyName.value, scope: newKeyScope.value }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
newKeyValue.value = data.key;
|
|
||||||
newKeyName.value = '';
|
|
||||||
await fetchApiKeys();
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
creatingApiKey.value = false;
|
creatingApiKey.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revokeApiKey(id: number) {
|
async function revokeApiKey(id: number) {
|
||||||
await fetch(`/api/api-keys/${id}`, { method: 'DELETE', credentials: 'include' });
|
await apiRevokeApiKey(id);
|
||||||
revokeConfirmId.value = null;
|
revokeConfirmId.value = null;
|
||||||
await fetchApiKeys();
|
await fetchApiKeys();
|
||||||
}
|
}
|
||||||
@@ -521,9 +516,8 @@ onMounted(async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// base URL not configured yet
|
// base URL not configured yet
|
||||||
}
|
}
|
||||||
if (activeTab.value === "groups") loadGroupsPanel();
|
|
||||||
if (activeTab.value === "briefing") loadBriefingTab();
|
|
||||||
}
|
}
|
||||||
|
_loadTabContent(activeTab.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function changeEmail() {
|
async function changeEmail() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class TaskStatus(str, enum.Enum):
|
|||||||
todo = "todo"
|
todo = "todo"
|
||||||
in_progress = "in_progress"
|
in_progress = "in_progress"
|
||||||
done = "done"
|
done = "done"
|
||||||
|
cancelled = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
class TaskPriority(str, enum.Enum):
|
class TaskPriority(str, enum.Enum):
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import httpx
|
|||||||
from quart import Blueprint, Response, jsonify, request
|
from quart import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.routes.utils import not_found
|
from fabledassistant.routes.utils import not_found, parse_pagination
|
||||||
from fabledassistant.config import Config
|
from fabledassistant.config import Config
|
||||||
from fabledassistant.services.chat import (
|
from fabledassistant.services.chat import (
|
||||||
add_message,
|
add_message,
|
||||||
@@ -38,8 +38,7 @@ chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
|
|||||||
@login_required
|
@login_required
|
||||||
async def list_conversations_route():
|
async def list_conversations_route():
|
||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
limit, offset = parse_pagination()
|
||||||
offset = request.args.get("offset", 0, type=int)
|
|
||||||
conv_type = request.args.get("type", "chat")
|
conv_type = request.args.get("type", "chat")
|
||||||
# Apply retention policy before returning list
|
# Apply retention policy before returning list
|
||||||
retention_str = await get_setting(uid, "chat_retention_days", "90")
|
retention_str = await get_setting(uid, "chat_retention_days", "90")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import logging
|
|||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.routes.utils import not_found
|
from fabledassistant.routes.utils import not_found, parse_pagination
|
||||||
from fabledassistant.services.milestones import (
|
from fabledassistant.services.milestones import (
|
||||||
create_milestone,
|
create_milestone,
|
||||||
delete_milestone,
|
delete_milestone,
|
||||||
@@ -107,8 +107,7 @@ async def get_milestone_tasks_route(project_id: int, milestone_id: int):
|
|||||||
if milestone is None or milestone.project_id != project_id:
|
if milestone is None or milestone.project_id != project_id:
|
||||||
return not_found("Milestone")
|
return not_found("Milestone")
|
||||||
status_filter = request.args.get("status")
|
status_filter = request.args.get("status")
|
||||||
limit = min(request.args.get("limit", 100, type=int), 500)
|
limit, offset = parse_pagination(default_limit=100)
|
||||||
offset = request.args.get("offset", 0, type=int)
|
|
||||||
notes, total = await list_notes(
|
notes, total = await list_notes(
|
||||||
uid,
|
uid,
|
||||||
is_task=True,
|
is_task=True,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fabledassistant.services.embeddings import upsert_note_embedding
|
|||||||
from quart import Blueprint, Response, jsonify, request
|
from quart import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.routes.utils import not_found, parse_iso_date
|
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
|
||||||
from fabledassistant.config import Config
|
from fabledassistant.config import Config
|
||||||
from fabledassistant.services.assist import build_assist_messages
|
from fabledassistant.services.assist import build_assist_messages
|
||||||
from fabledassistant.services.generation_buffer import (
|
from fabledassistant.services.generation_buffer import (
|
||||||
@@ -49,8 +49,7 @@ async def list_notes_route():
|
|||||||
tag = request.args.getlist("tag")
|
tag = request.args.getlist("tag")
|
||||||
sort = request.args.get("sort", "updated_at")
|
sort = request.args.get("sort", "updated_at")
|
||||||
order = request.args.get("order", "desc")
|
order = request.args.get("order", "desc")
|
||||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
limit, offset = parse_pagination()
|
||||||
offset = request.args.get("offset", 0, type=int)
|
|
||||||
|
|
||||||
# Default to non-task notes only; ?is_task=true for tasks, ?all=true for everything
|
# Default to non-task notes only; ?is_task=true for tasks, ?all=true for everything
|
||||||
is_task: bool | None = False
|
is_task: bool | None = False
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import logging
|
|||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.routes.utils import not_found
|
from fabledassistant.routes.utils import not_found, parse_pagination
|
||||||
from fabledassistant.services.milestones import list_milestones
|
from fabledassistant.services.milestones import list_milestones
|
||||||
from fabledassistant.services.notes import list_notes
|
from fabledassistant.services.notes import list_notes
|
||||||
from fabledassistant.services.projects import (
|
from fabledassistant.services.projects import (
|
||||||
@@ -100,8 +100,7 @@ async def get_project_notes_route(project_id: int):
|
|||||||
# type filter: "note", "task", or None (both)
|
# type filter: "note", "task", or None (both)
|
||||||
type_filter = request.args.get("type")
|
type_filter = request.args.get("type")
|
||||||
status_filter = request.args.get("status")
|
status_filter = request.args.get("status")
|
||||||
limit = min(request.args.get("limit", 100, type=int), 500)
|
limit, offset = parse_pagination(default_limit=100)
|
||||||
offset = request.args.get("offset", 0, type=int)
|
|
||||||
|
|
||||||
is_task: bool | None = None
|
is_task: bool | None = None
|
||||||
if type_filter == "task":
|
if type_filter == "task":
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from quart import Blueprint, jsonify, request
|
|||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.models.note import TaskPriority, TaskStatus
|
from fabledassistant.models.note import TaskPriority, TaskStatus
|
||||||
from fabledassistant.routes.utils import not_found, parse_iso_date
|
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
|
||||||
from fabledassistant.services.embeddings import upsert_note_embedding
|
from fabledassistant.services.embeddings import upsert_note_embedding
|
||||||
from fabledassistant.services.notes import (
|
from fabledassistant.services.notes import (
|
||||||
create_note,
|
create_note,
|
||||||
@@ -28,8 +28,7 @@ async def list_tasks_route():
|
|||||||
priority = request.args.get("priority")
|
priority = request.args.get("priority")
|
||||||
sort = request.args.get("sort", "updated_at")
|
sort = request.args.get("sort", "updated_at")
|
||||||
order = request.args.get("order", "desc")
|
order = request.args.get("order", "desc")
|
||||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
limit, offset = parse_pagination()
|
||||||
offset = request.args.get("offset", 0, type=int)
|
|
||||||
|
|
||||||
project_id = request.args.get("project_id", type=int)
|
project_id = request.args.get("project_id", type=int)
|
||||||
no_project = request.args.get("no_project", "").lower() == "true"
|
no_project = request.args.get("no_project", "").lower() == "true"
|
||||||
@@ -72,10 +71,14 @@ async def create_task_route():
|
|||||||
if isinstance(due_date, tuple):
|
if isinstance(due_date, tuple):
|
||||||
return due_date
|
return due_date
|
||||||
|
|
||||||
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
|
try:
|
||||||
priority = (
|
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
|
||||||
TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
|
except ValueError:
|
||||||
)
|
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
|
||||||
|
try:
|
||||||
|
priority = TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
|
||||||
|
|
||||||
project_id = data.get("project_id")
|
project_id = data.get("project_id")
|
||||||
if project_id is None and data.get("project"):
|
if project_id is None and data.get("project"):
|
||||||
@@ -118,15 +121,25 @@ async def get_task_route(task_id: int):
|
|||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
@tasks_bp.route("/<int:task_id>", methods=["PUT"])
|
@tasks_bp.route("/<int:task_id>", methods=["PUT", "PATCH"])
|
||||||
@login_required
|
@login_required
|
||||||
async def update_task_route(task_id: int):
|
async def update_task_route(task_id: int):
|
||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
fields = {}
|
fields = {}
|
||||||
for key in ("title", "status", "priority"):
|
for key in ("title",):
|
||||||
if key in data:
|
if key in data:
|
||||||
fields[key] = data[key]
|
fields[key] = data[key]
|
||||||
|
if "status" in data:
|
||||||
|
try:
|
||||||
|
fields["status"] = TaskStatus(data["status"]).value
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
|
||||||
|
if "priority" in data:
|
||||||
|
try:
|
||||||
|
fields["priority"] = TaskPriority(data["priority"]).value
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
|
||||||
|
|
||||||
# Accept both "body" and "description" (prefer body)
|
# Accept both "body" and "description" (prefer body)
|
||||||
if "body" in data:
|
if "body" in data:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from quart import jsonify
|
from quart import jsonify, request
|
||||||
|
|
||||||
|
|
||||||
def not_found(resource: str = "Item"):
|
def not_found(resource: str = "Item"):
|
||||||
@@ -15,3 +15,10 @@ def parse_iso_date(value: str | None, field: str = "date"):
|
|||||||
return date.fromisoformat(value)
|
return date.fromisoformat(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": f"Invalid {field} format. Use YYYY-MM-DD."}), 400
|
return jsonify({"error": f"Invalid {field} format. Use YYYY-MM-DD."}), 400
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pagination(default_limit: int = 50, max_limit: int = 500) -> tuple[int, int]:
|
||||||
|
"""Extract and clamp ``limit`` / ``offset`` from the current request's query string."""
|
||||||
|
limit = min(request.args.get("limit", default_limit, type=int), max_limit)
|
||||||
|
offset = request.args.get("offset", 0, type=int)
|
||||||
|
return limit, offset
|
||||||
|
|||||||
@@ -416,6 +416,18 @@ async def run_compilation(
|
|||||||
max_items=10,
|
max_items=10,
|
||||||
)
|
)
|
||||||
rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")]
|
rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")]
|
||||||
|
rss_items_meta = [
|
||||||
|
{
|
||||||
|
"id": item["id"],
|
||||||
|
"title": item.get("title", ""),
|
||||||
|
"url": item.get("url", ""),
|
||||||
|
"source": item.get("feed_title", ""),
|
||||||
|
"snippet": (item.get("content") or "")[:300],
|
||||||
|
"published_at": item.get("published_at"),
|
||||||
|
}
|
||||||
|
for item in filtered_rss
|
||||||
|
if item.get("id")
|
||||||
|
]
|
||||||
|
|
||||||
# Weather staleness gate — returns None if data is >24h old
|
# Weather staleness gate — returns None if data is >24h old
|
||||||
weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None
|
weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None
|
||||||
@@ -449,7 +461,7 @@ async def run_compilation(
|
|||||||
# ── Post-processing ─────────────────────────────────────────────────────────
|
# ── Post-processing ─────────────────────────────────────────────────────────
|
||||||
await upsert_task_snapshots(user_id, all_tasks)
|
await upsert_task_snapshots(user_id, all_tasks)
|
||||||
|
|
||||||
metadata: dict = {"rss_item_ids": rss_item_ids, "weather": weather_card}
|
metadata: dict = {"rss_item_ids": rss_item_ids, "rss_items": rss_items_meta, "weather": weather_card}
|
||||||
|
|
||||||
if not internal_text and not external_text:
|
if not internal_text and not external_text:
|
||||||
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
|
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
|
||||||
|
|||||||
@@ -16,6 +16,33 @@ logger = logging.getLogger(__name__)
|
|||||||
VALID_PERMISSIONS = {"viewer", "editor", "admin"}
|
VALID_PERMISSIONS = {"viewer", "editor", "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _enrich_shares(session, shares) -> list[dict]:
|
||||||
|
"""Attach username / group_name to a list of share rows (ProjectShare or NoteShare)."""
|
||||||
|
result = []
|
||||||
|
for s in shares:
|
||||||
|
d = s.to_dict()
|
||||||
|
if s.shared_with_user_id:
|
||||||
|
user = await session.get(User, s.shared_with_user_id)
|
||||||
|
d["username"] = user.username if user else None
|
||||||
|
if s.shared_with_group_id:
|
||||||
|
group = await session.get(Group, s.shared_with_group_id)
|
||||||
|
d["group_name"] = group.name if group else None
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate_by_permission(shares, id_attr: str) -> dict[int, str]:
|
||||||
|
"""Return {resource_id: best_permission} keeping the highest-ranked permission per resource."""
|
||||||
|
from fabledassistant.services.access import PERMISSION_RANK
|
||||||
|
seen: dict[int, str] = {}
|
||||||
|
for share in shares:
|
||||||
|
rid = getattr(share, id_attr)
|
||||||
|
prev = seen.get(rid)
|
||||||
|
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
|
||||||
|
seen[rid] = share.permission
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Project shares
|
# Project shares
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -83,17 +110,7 @@ async def list_project_shares(project_id: int) -> list[dict]:
|
|||||||
shares = (await session.execute(
|
shares = (await session.execute(
|
||||||
select(ProjectShare).where(ProjectShare.project_id == project_id)
|
select(ProjectShare).where(ProjectShare.project_id == project_id)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
result = []
|
return await _enrich_shares(session, shares)
|
||||||
for s in shares:
|
|
||||||
d = s.to_dict()
|
|
||||||
if s.shared_with_user_id:
|
|
||||||
user = await session.get(User, s.shared_with_user_id)
|
|
||||||
d["username"] = user.username if user else None
|
|
||||||
if s.shared_with_group_id:
|
|
||||||
group = await session.get(Group, s.shared_with_group_id)
|
|
||||||
d["group_name"] = group.name if group else None
|
|
||||||
result.append(d)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -166,17 +183,7 @@ async def list_note_shares(note_id: int) -> list[dict]:
|
|||||||
shares = (await session.execute(
|
shares = (await session.execute(
|
||||||
select(NoteShare).where(NoteShare.note_id == note_id)
|
select(NoteShare).where(NoteShare.note_id == note_id)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
result = []
|
return await _enrich_shares(session, shares)
|
||||||
for s in shares:
|
|
||||||
d = s.to_dict()
|
|
||||||
if s.shared_with_user_id:
|
|
||||||
user = await session.get(User, s.shared_with_user_id)
|
|
||||||
d["username"] = user.username if user else None
|
|
||||||
if s.shared_with_group_id:
|
|
||||||
group = await session.get(Group, s.shared_with_group_id)
|
|
||||||
d["group_name"] = group.name if group else None
|
|
||||||
result.append(d)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -203,12 +210,7 @@ async def list_shared_with_me(user_id: int) -> dict:
|
|||||||
)
|
)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
|
||||||
seen_projects: dict[int, str] = {}
|
seen_projects = _deduplicate_by_permission(list(proj_direct) + list(proj_group), "project_id")
|
||||||
for share in list(proj_direct) + list(proj_group):
|
|
||||||
prev = seen_projects.get(share.project_id)
|
|
||||||
from fabledassistant.services.access import PERMISSION_RANK
|
|
||||||
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
|
|
||||||
seen_projects[share.project_id] = share.permission
|
|
||||||
|
|
||||||
projects = []
|
projects = []
|
||||||
for pid, perm in seen_projects.items():
|
for pid, perm in seen_projects.items():
|
||||||
@@ -239,12 +241,7 @@ async def list_shared_with_me(user_id: int) -> dict:
|
|||||||
)
|
)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
|
||||||
seen_notes: dict[int, str] = {}
|
seen_notes = _deduplicate_by_permission(list(note_direct) + list(note_group), "note_id")
|
||||||
for share in list(note_direct) + list(note_group):
|
|
||||||
prev = seen_notes.get(share.note_id)
|
|
||||||
from fabledassistant.services.access import PERMISSION_RANK
|
|
||||||
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
|
|
||||||
seen_notes[share.note_id] = share.permission
|
|
||||||
|
|
||||||
notes = []
|
notes = []
|
||||||
for nid, perm in seen_notes.items():
|
for nid, perm in seen_notes.items():
|
||||||
|
|||||||
Reference in New Issue
Block a user