Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config/focuses.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
main:
- "!institution :: Mila :: 10"
- "!author :: Yoshua Bengio :: 3"
- "!author :: Yoshua Bengio :: 3"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ license = "MIT"
requires-python = ">=3.12"
dependencies = [
"ovld>=0.5.13",
"serieux>=0.3.8",
"serieux>=0.3.9",
"gifnoc>=0.6.2",
"beautifulsoup4>=4.13.4",
"blessed>=1.21.0",
Expand Down
32 changes: 6 additions & 26 deletions src/paperoni/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
dump,
serialize,
)
from serieux.features.filebacked import FileProxy
from serieux.features.tagset import FromEntryPoint

from .client.utils import login
Expand Down Expand Up @@ -870,48 +871,27 @@ class AutoFocus:
timespan: timedelta = timedelta(weeks=52)

async def run(self, focus: "Focus"):
focuses = focus.focuses or config.focuses
start_date = datetime.now() - self.timespan
start_date = start_date.date().replace(month=1, day=1)
focuses = Focuses()
focus.focuses.update(
focuses.update(
[p async for p in focus.collection.search(start_date=start_date)],
config.autofocus,
dest=focuses,
)
dump(Focuses, focuses, dest=focus.autofocus_file)
return focus.focuses
focuses.save()
return focuses

# Command to execute
command: TaggedUnion[AutoFocus]

# List of focuses
# [option: -f]
_focus_file: Path = None
focuses: Focuses @ FileProxy() = None

# Collection dir
# [alias: -c]
collection_file: Path = None

@cached_property
def focus_file(self):
focus_file = self._focus_file
if focus_file is None:
f = config.metadata.focuses.file
focus_file = f if f.exists() else None
return focus_file

@cached_property
def autofocus_file(self):
focus_file = self.focus_file
return (
focus_file.parent / f"auto{focus_file.name}"
or config.metadata.focuses.autofile
)

@cached_property
def focuses(self):
return deserialize(Focuses, self.focus_file)

@cached_property
def collection(self):
if self.collection_file:
Expand Down
27 changes: 4 additions & 23 deletions src/paperoni/config.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import os
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from typing import Literal

import gifnoc
from easy_oauth import OAuthManager
from rapporteur.report import Reporter
from serieux import TaggedSubclass
from serieux.features.encrypt import Secret
from serieux.features.filebacked import FileProxy

from .collection.abc import PaperCollection
from .get import Fetcher, RequestsFetcher
Expand Down Expand Up @@ -64,34 +64,15 @@ class PaperoniConfig:
mailto: str = ""
api_keys: Keys[str, Secret[str]] = field(default_factory=Keys)
fetch: TaggedSubclass[Fetcher] = field(default_factory=RequestsFetcher)
focuses: Focuses = field(default_factory=Focuses)
autofocus: AutoFocus[str, AutoFocus.Author] = field(default_factory=AutoFocus)
focuses: Focuses @ FileProxy(refresh=True) = field(default_factory=Focuses)
autofocus: AutoFocus = field(default_factory=AutoFocus)
autovalidate: AutoValidate = field(default_factory=AutoValidate)
refine: Refine = None
work_file: Path = None
collection: TaggedSubclass[PaperCollection] = None
reporters: list[TaggedSubclass[Reporter]] = field(default_factory=list)
server: Server = field(default_factory=Server)

def __post_init__(self):
self.metadata: Meta[Path | list[Path] | Meta | Any] = Meta()

g_file = os.environ.get("GIFNOC_FILE", None)
g_file = (g_file and g_file.split(",")) or []
m_files = self.metadata.files or g_file
self.metadata.files = [Path(f).resolve() for f in m_files]
self.metadata.file = (self.metadata.files and self.metadata.files[0]) or None

self.metadata.focuses = Meta()

# The focuses and autofocuses files should probably always be relative
# to the config file and use focuses.yaml and autofocuses.yaml as names.
if self.metadata.file and self.metadata.file.exists():
self.metadata.focuses.file = self.metadata.file.parent / "focuses.yaml"
self.metadata.focuses.autofile = Path(self.metadata.focuses.file).with_stem(
f"auto{self.metadata.focuses.file.stem}"
)

# TODO: Why does this seams to disable future gifnoc.define like
# `paperoni.semantic_scholar`?
# gifnoc.proxy.MissingConfigurationError: No configuration was found for 'paperoni.semantic_scholar'
Expand Down
58 changes: 24 additions & 34 deletions src/paperoni/model/focus.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from outsight import send
from ovld import ovld
from serieux import Field, Model

from ..utils import (
mostly_latin,
Expand Down Expand Up @@ -56,10 +55,15 @@ def combine(scores):

@dataclass
class Focuses:
focuses: list[Focus] = field(default_factory=list)
main: list[Focus] = field(default_factory=list)
auto: list[Focus] = field(default_factory=list)

def __post_init__(self):
self.compile()

def compile(self):
self.score_index = {}
self.focuses = list(self.main + self.auto)
for f in self.focuses:
match f.type.split("_")[0]:
case "author":
Expand All @@ -69,14 +73,6 @@ def __post_init__(self):
case _:
raise ValueError(f"Invalid focus type: {f.type}")

@classmethod
def serieux_model(cls, call_next):
return Model(
original_type=cls,
element_field=Field(name="_", type=Focus),
from_list=cls,
)

def __iter__(self):
return iter(self.focuses)

Expand Down Expand Up @@ -118,19 +114,17 @@ def top(self, papers, n, drop_zero=True):
t.add(scored)
return t

def update(
self, papers: Iterable[Paper], autofocus: "AutoFocus", dest: Focuses = None
) -> Focuses:
def update(self, papers: Iterable[Paper], autofocus: "AutoFocus"):
# TODO: store each auto focus with a start / end date to avoid
# considering the papers of authors not at Mila anymore
dest = dest or self

focused_institutions_cnts = {
normalize_institution(f.name): Counter()
for f in self.focuses
for f in self.main
if f.type == "institution"
and f.score >= autofocus.author.institution_score_threshold
}
focused_authors = {f.name for f in self.focuses if f.type == "author"}
focused_authors = {f.name for f in self.main if f.type == "author"}

for paper in papers:
for author in paper.authors:
Expand All @@ -143,40 +137,36 @@ def update(
set([author.display_name, *author.author.aliases])
)

self.auto.clear()
for counter in focused_institutions_cnts.values():
for author_name, count in counter.most_common():
if (
count >= autofocus.author.threshold
and author_name not in focused_authors
):
dest.focuses.append(
self.auto.append(
Focus(
type="author", name=author_name, score=autofocus.author.score
)
)
focused_authors.add(author_name)

# sort focuses by institution first, then author
dest.focuses = sorted(
(f for f in dest.focuses if f.type == "institution"),
key=lambda x: x.score,
reverse=True,
) + sorted(
(f for f in dest.focuses if f.type == "author"),
key=lambda x: x.score,
reverse=True,
)
order = {"institution": 0, "author": 1}
self.auto.sort(key=lambda x: (order[x.type], -x.score, x.name))
self.compile()


class AutoFocus(dict):
@dataclass
class Author:
score: int
institution_score_threshold: int
threshold: int = 1
@dataclass
class AuthorFocusPolicy:
score: int
institution_score_threshold: int
threshold: int = 1

def __getattr__(self, attr: str) -> "AutoFocus.Author":
return self[attr]

@dataclass
class AutoFocus:
author: AuthorFocusPolicy = None


@dataclass(order=True)
Expand Down
38 changes: 38 additions & 0 deletions src/paperoni/web/assets/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,41 @@ export function debounce(func, wait) {
timeout = setTimeout(later, wait);
};
}

/**
* Show a toast notification
*/
export function showToast(message, type = 'success') {
// Create toast container if it doesn't exist
// Implementation of showToast extracted from edit.js but made generic
// We assume the CSS handles positioning relative to the viewport (.toast { position: fixed ... })

// Check for existing toast with same message to prevent stacking
const existing = Array.from(document.querySelectorAll('.toast')).find(t => t.textContent.includes(message));
if (existing) return;

const toast = document.createElement('div');
toast.className = `toast toast-${type}`;

const icon = type === 'success' ? '✓' : '✕';

toast.innerHTML = `
<span class="toast-icon">${icon}</span>
<span class="toast-message">${message}</span>
<button class="toast-close">×</button>
`;

document.body.appendChild(toast);

const closeBtn = toast.querySelector('.toast-close');

function hide() {
toast.classList.add('toast-hiding');
toast.addEventListener('animationend', () => {
toast.remove();
});
}

closeBtn.addEventListener('click', hide);
setTimeout(hide, 5000);
}
37 changes: 1 addition & 36 deletions src/paperoni/web/assets/edit.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { html } from './common.js';
import { html, showToast } from './common.js';

/**
* Generic badge management system for topics, flags, etc.
Expand Down Expand Up @@ -71,41 +71,6 @@ function createBadge(item, index, items, renderBadges, config) {
return badge;
}

/**
* Show a toast notification
*/
function showToast(message, type = 'success') {
// Remove any existing toast
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}

const toast = html`
<div class="toast toast-${type}">
<span class="toast-message">${message}</span>
<button class="toast-close" type="button">×</button>
</div>
`;

// Add close functionality
toast.querySelector('.toast-close').addEventListener('click', () => {
toast.classList.add('toast-hiding');
setTimeout(() => toast.remove(), 300);
});

// Add to page
document.body.appendChild(toast);

// Auto-hide after 4 seconds
setTimeout(() => {
if (toast.parentNode) {
toast.classList.add('toast-hiding');
setTimeout(() => toast.remove(), 300);
}
}, 4000);
}

/**
* Main function to set up the paper edit page
*/
Expand Down
Loading
Loading