% \iffalse meta-comment % % problib.dtx % Copyright (C) 2026 Kyle Monette % https://github.com/kylemonette/problib % % This work may be distributed and/or modified under the % conditions of the LaTeX Project Public License, either version 1.3c % of this license or (at your option) any later version. % The latest version of this license is in % https://www.latex-project.org/lppl.txt % and version 1.3c or later is part of all distributions of LaTeX % version 2008/05/04 or later. % % This work has the LPPL maintenance status `maintained'. % % The Current Maintainer of this work is Kyle Monette. % % This work consists of the files problib.dtx and % problib.ins and the derived file problib.sty. % % \fi % % \iffalse %\NeedsTeXFormat{LaTeX2e}[2023-11-01] %\ProvidesExplPackage{problib}{2026-10-04}{1.0} % {Problem library with usage tracking for the exam class} % %<*driver> \documentclass[11pt]{exam} \usepackage[T1]{fontenc} \usepackage{lmodern} \usepackage{amsmath} \usepackage{booktabs} \usepackage{geometry} \geometry{margin=0.8in} \usepackage{doc} \usepackage{showexpl} \lstset{ basicstyle=\ttfamily\small, breaklines=true, breakatwhitespace=false, columns=fullflexible, showstringspaces=false, explpreset={pos=b,numbers=none,rframe={},width=\linewidth} } \usepackage[hyperindex=false]{hyperref} \hypersetup{colorlinks=true} \begin{filecontents*}[overwrite]{demo-derivatives.tex} \begin{problem}[ title = Derivatives, topics = {calculus}, tags = {chain rule, product rule}, difficulty = 2, ] Differentiate each function. \begin{parts} \part[3] $f(x) = \sin(x^2)$ \part[3] $g(x) = x^2 e^x$ \part[4] $h(x) = \ln(\cos x)$ \end{parts} \begin{solution} (a) $2x\cos(x^2)$ \qquad (b) $(2x + x^2)e^x$ \qquad (c) $-\tan x$ \end{solution} \end{problem} \end{filecontents*} \begin{filecontents*}[overwrite]{demo-integral.tex} \begin{problem}[ title = Substitution, topics = {calculus}, tags = {u-substitution}, difficulty = 2, points = 5, ] Evaluate $\displaystyle\int x\sqrt{x^2 + 1}\,dx$. \begin{solution} $\frac{1}{3}(x^2 + 1)^{3/2} + C$ \end{solution} \end{problem} \end{filecontents*} \begin{filecontents*}[overwrite]{demo-eigen.tex} \begin{problem}[ title = Eigenvalues, topics = {linear algebra}, difficulty = 3, ] Let $A = \begin{pmatrix} 2 & 1 \\ 1 & 2 \end{pmatrix}$. \begin{parts} \part Find the eigenpairs of $A$. \begin{subparts} \subpart[3] Eigenvalues. \subpart[3] Eigenvectors. \end{subparts} \part[4] Diagonalize $A$. \end{parts} \end{problem} \end{filecontents*} \begin{filecontents*}[overwrite]{usage-db.tex} \problibusage{demo-derivatives}{\problibentry{2026-02-14}{Quiz 2}{MATH 221}{Spring 2026}{quiz}{quiz2.pbu}\problibentry{2025-09-30}{Worksheet 4}{MATH 221}{Fall 2025}{worksheet}{ws4.pbu}} \end{filecontents*} \usepackage{problib} \problibassignment{name = problib manual, record = false} \newcommand\pl{\textsf{problib}} \providecommand\marg[1]{\texttt{\{}\meta{#1}\texttt{\}}} \providecommand\oarg[1]{\texttt{[}\meta{#1}\texttt{]}} \title{\texttt{problib.sty}\\[0.5ex] \large A problem library with usage tracking for the \texttt{exam} class} \author{Kyle Monette (\href{https://kylemonette.github.io}{kylemonette.github.io}) \\ \small Released under the LaTeX Project Public License (LPPL), version 1.3c} \date{\today\ --- Version 1.0} \pagestyle{plain} \begin{document} \maketitle \section*{Overview} \pl{} keeps a library of individual problems, each stored in its own file with metadata such as topics, tags and difficulty. Assignments are \texttt{exam}-class documents that pull problems from the library by path. Settings that belong to the assignment rather than the problem, such as the blank space left for students' work, are made in the assignment itself, so the same problem can be reused anywhere. Every assignment records which problems it used. A catalog document, compiled with Lua\LaTeX, gathers those records into a usage history and typesets the library with each problem's metadata and the assignments it appeared in. Assignments can show that history next to each problem and warn when a problem was used recently. A typical workflow is: \begin{enumerate} \item write problems into the library (Section~\ref{sec:problems}); \item build assignments from them with \cs{useproblem} (Section~\ref{sec:assignments}); \item compile the catalog to update the usage history (Section~\ref{sec:catalog}). \end{enumerate} \noindent Complete examples are in the \pl{} repository at \url{https://github.com/kylemonette/problib}. \tableofcontents \section{Quick reference} \noindent \begin{tabularx}{\linewidth}{@{}lX@{}} \toprule \multicolumn{2}{@{}l}{Problem files} \\ \midrule \texttt{\textbackslash begin\{problem\}[metadata]} & Holds one problem in a library file; its metadata goes in the optional argument. \\ \cs{problemmeta}\marg{key} & Expands to a metadata value of the problem being typeset. \\ \midrule \multicolumn{2}{@{}l}{Assignments} \\ \midrule \cs{usepackage}\oarg{options}\texttt{\{problib\}} & Loads the package; options are the keys of \cs{problibsetup}, usually \texttt{library}. \\ \cs{problibassignment}\marg{keys} & Preamble only. Records the assignment's name, course, term, type and date. \\ \cs{problibsetup}\marg{keys} & Sets defaults: library folder, \texttt{showdata}, \texttt{warnreuse}, \ldots \\ \cs{useproblem}\oarg{options}\marg{path} & Typesets the problem at \meta{path} and records its use. \\ \cs{problemspace}\oarg{style}\marg{length} & Adds answer space at that point. \\ \midrule \multicolumn{2}{@{}l}{Catalog (using Lua\LaTeX)} \\ \midrule \cs{usepackage}\texttt{[catalog]\{problib\}} & Loads the package in catalog mode; the \texttt{roots} option lists the folders holding assignments. \\ \cs{problibcatalog}\oarg{filters} & Updates the usage database and typesets the matching problems with their metadata and usage. \\ \bottomrule \end{tabularx} \section{Requirements} \pl{} requires the \texttt{exam} document class and a \LaTeX{} kernel from November 2023 or later; it loads \texttt{xcolor} and \texttt{tabularx}. Assignments compile with pdf\LaTeX, Xe\LaTeX{} or Lua\LaTeX. The catalog requires Lua\LaTeX, which it uses to list the contents of folders. \section{Problem files}\label{sec:problems} A library is a folder of problem files, organized into subfolders however you like. Each file holds one \texttt{problem} environment and nothing else. A problem is referred to by its path inside the library, without the \texttt{.tex} extension. \begin{lstlisting} \begin{problem}[ title = Chain rule with trig, topics = {calculus, derivatives}, tags = {chain rule, trig}, difficulty = 2, time = 5, author = John Smith, created = 2025-08-14, ] Differentiate each function. \begin{parts} \part[4] $f(x) = \sin(x^2)$ \part[6] $g(x) = \cos^3(2x)$ \end{parts} \begin{solution} (a) $2x\cos(x^2)$ \qquad (b) $-6\cos^2(2x)\sin(2x)$ \end{solution} \end{problem} \end{lstlisting} The body is written exactly as inside an \texttt{exam} \cs{question}: use \texttt{parts}, \texttt{subparts}, \cs{part}\oarg{points}, \texttt{solution} and the other environments of the class. \subsection{Metadata} The optional argument of \texttt{problem} is a list of \meta{key}\texttt{=}\meta{value} pairs. Any key may be used, and every key is shown in the catalog in the order written. A few keys have a special meaning: \medskip \noindent \begin{tabularx}{\linewidth}{@{}lX@{}} \toprule \texttt{title} & Shown next to the problem's path in the catalog and in \texttt{showdata} notes. \\ \texttt{points} & The question's point value, passed to \cs{question}\oarg{points}. \\ \texttt{topics}, \texttt{tags} & Comma lists, matched by the catalog filters of the same names. \\ \texttt{difficulty} & An integer, used by the catalog to filter and sort. \\ \bottomrule \end{tabularx} \medskip Brace a value that contains a comma or a closing bracket, as in \texttt{tags = \{chain rule, trig\}}. Give points either with \texttt{points} or on the individual parts, not both: \texttt{exam} adds them together. Inside the problem, \cs{problemmeta}\marg{key} expands to the value of \meta{key}, or to nothing if the key is not set; for example, \verb|\problemmeta{time}| gives the estimated time of the problem above. \section{Building an assignment}\label{sec:assignments} An assignment is an \texttt{exam} document that loads \pl{}, describes itself with \cs{problibassignment}, and calls \cs{useproblem} inside a \texttt{questions} environment. Its own \cs{question}s can be mixed freely with library problems. \begin{lstlisting} \documentclass[addpoints]{exam} \usepackage{amsmath} \usepackage[library = /Users/me/problems]{problib} \problibassignment{ name = Exam 1, course = MATH 221, term = Fall 2026, type = exam, date = 2026-10-12, } \problibsetup{problemspace = \fill, partspace = 1.5in, warnreuse = 120} \begin{document} \begin{questions} \useproblem{calculus/derivatives/chain-rule-01} \useproblem[points = 10]{calculus/integrals/u-sub-01} \useproblem[newpage]{calculus/limits/squeeze-01} \end{questions} \end{document} \end{lstlisting} \subsection{Settings} \cs{problibsetup}\marg{keys} sets the defaults for the whole document. The same keys may be given as package options. \medskip \noindent \begin{tabularx}{\linewidth}{@{}lX@{}} \toprule \texttt{library} & The folder holding the problems, absolute or relative to the assignment's folder. Without it, problem paths are taken relative to the assignment's folder. \\ \texttt{problemspace}, \ldots & The answer-space keys \texttt{problemspace}, \texttt{partspace}, \texttt{subpartspace}, \texttt{spacestyle} and \texttt{keyspace}; see Section~\ref{sec:space}. \\ \texttt{showdata} & Show each problem's usage history above it; see Section~\ref{sec:showdata}. \\ \texttt{warnreuse} & Warn about recently used problems; see Section~\ref{sec:warnreuse}. \\ \texttt{roots} & Catalog only; see Section~\ref{sec:catalog}. \\ \bottomrule \end{tabularx} \subsection{Describing the assignment} \cs{problibassignment}\marg{keys} may only be used in the preamble. Its keys are written to the assignment's usage record (Section~\ref{sec:usage}) and identify the assignment in the usage history. \medskip \noindent \begin{tabularx}{\linewidth}{@{}lX@{}} \toprule \texttt{name} & Name of the assignment. Default: the file name. \\ \texttt{course}, \texttt{term}, \texttt{type} & Free text, shown in the usage history. \\ \texttt{date} & \texttt{YYYY-MM-DD}. Default: the day of compilation. Orders the history and is the reference date for \texttt{warnreuse}. \\ \texttt{record} & \texttt{false} keeps the assignment out of the usage history (drafts, practice versions). Default: \texttt{true}. \\ \bottomrule \end{tabularx} \subsection{Inserting problems} \cs{useproblem}\oarg{options}\marg{path} reads the file \meta{library}\texttt{/}\meta{path}\texttt{.tex}, adds \meta{path} to the assignment's usage record, and typesets the problem as the next \cs{question}, with the point value from its \texttt{points} metadata if it has one. A missing file is an error and is not recorded. The options apply to this problem only: \medskip \noindent \begin{tabularx}{\linewidth}{lX@{}} \toprule \texttt{space}, \texttt{partspace}, \texttt{subpartspace}, \texttt{spacestyle} & Answer space; see Section~\ref{sec:space}. \texttt{space} sets \texttt{problemspace}. \\ \texttt{points} & Points for the question, overriding the problem's \texttt{points}. \\ \texttt{newpage} & Start the question on a new page. \\ \bottomrule \end{tabularx} \begin{LTXexample} \begin{questions} \useproblem{demo-derivatives} \useproblem[points = 8]{demo-integral} \end{questions} \end{LTXexample} \section{Answer space}\label{sec:space} Answer space is chosen by the assignment and never stored in a problem. Defaults are set with \cs{problibsetup} and overridden for a single problem with the options of \cs{useproblem}. By default no space is added. \medskip \noindent \begin{tabularx}{\linewidth}{@{}lX@{}} \toprule \texttt{problemspace} & Space after a question that has no parts. \\ \texttt{partspace} & Space after each part. A single length applies to every part; a comma list gives the space after the first, second, \ldots{} part, and its last value is reused for any further parts. \\ \texttt{subpartspace} & The same, for the subparts of each part. \\ \texttt{spacestyle} & How the space is filled: \texttt{blank} (default), \texttt{lines}, \texttt{dottedlines}, \texttt{box} or \texttt{grid}. \\ \texttt{keyspace} & Keep the space in answer keys (when \cs{printanswers} is on). Default: \texttt{false}. \\ \bottomrule \end{tabularx} \medskip Lengths are anything \texttt{exam} accepts, including \cs{fill} and \cs{stretch}\marg{n}. The styles other than \texttt{blank} use \texttt{exam}'s \cs{fillwithlines}, \cs{fillwithdottedlines}, \cs{makeemptybox} and \cs{fillwithgrid}. Space goes only where answers are written, at the innermost level of a problem. A question with parts gets \texttt{partspace} after each part and no \texttt{problemspace}; a part with subparts gets \texttt{subpartspace} after each subpart and no \texttt{partspace}. Here each part gets its own space from a list: \begin{LTXexample} \begin{questions} \useproblem[partspace = {0.2in, 0.6in, 0.3in}]{demo-derivatives} \end{questions} \end{LTXexample} Here part (a) has subparts, so the space follows each subpart, while part (b) gets \texttt{partspace}: \begin{LTXexample} \begin{questions} \useproblem[partspace = 0.4in, subpartspace = 0.2in]{demo-eigen} \end{questions} \end{LTXexample} Questions without parts use \texttt{space}, shown here in two styles: \begin{LTXexample} \begin{questions} \useproblem[space = 0.8in, spacestyle = lines]{demo-integral} \useproblem[space = 0.8in, spacestyle = box]{demo-integral} \end{questions} \end{LTXexample} \cs{problemspace}\oarg{style}\marg{length} adds space at the point where it is used, for example after a \cs{question} of your own. It uses the current \texttt{spacestyle} unless \meta{style} is given. \subsection{Answer keys} \cs{printanswers} turns the same assignment into its answer key: solutions appear and answer space is dropped. Set \texttt{keyspace = true} to keep the space, so that the key has the same page layout as the assignment. \begin{LTXexample} \printanswers \begin{questions} \useproblem[partspace = 0.5in]{demo-derivatives} \end{questions} \end{LTXexample} \section{Usage tracking}\label{sec:usage} \TeX{} may write files only in the folder of the document being compiled, so each assignment keeps its own record. Compiling an assignment writes \meta{jobname}\texttt{.pbu} next to it, where \meta{jobname} is the file name of the assignment. The record lists the details from \cs{problibassignment} and every problem inserted with \cs{useproblem}. It is rewritten on every compilation, so it always matches the current assignment, and it never needs editing. Deleting it removes the assignment from the history; compiling the assignment again restores it. Compiling the catalog (Section~\ref{sec:catalog}) collects every record under the \texttt{roots} folders into the usage database, \texttt{usage-db.tex} in the library. Assignments read that file, so the history they see is as current as the last catalog compilation. Until the catalog has been compiled once, assignments compile normally and \pl{} issues a warning that the database is missing. \subsection{Showing usage in an assignment}\label{sec:showdata} With \texttt{showdata = true}, every problem is preceded by its path, its title, and a table of its earlier uses, newest first. \begin{LTXexample} \problibsetup{showdata = true} \begin{questions} \useproblem{demo-derivatives} \useproblem{demo-integral} \end{questions} \end{LTXexample} \subsection{Reuse warnings}\label{sec:warnreuse} \texttt{warnreuse = }\meta{days} checks each problem's most recent use in another assignment. If that use is less than \meta{days} days before the assignment's \texttt{date} (or after it), \pl{} writes a warning to the log, for example \begin{quote}\small \verb|Package problib Warning: Problem 'demo-derivatives' was used in|\\ \verb|(problib) 'Quiz 2 (Spring 2026)' on 2026-02-14.| \end{quote} The default, \texttt{0}, disables the check. \section{The catalog}\label{sec:catalog} The catalog is a Lua\LaTeX{} document that lives in the library folder and must be compiled there: it treats the folder it is compiled in as the library. \begin{lstlisting} \documentclass{exam} \usepackage{amsmath} \usepackage[catalog, roots = {~/Teaching}]{problib} \begin{document} \problibcatalog \end{document} \end{lstlisting} \noindent Each compilation \begin{enumerate} \item finds every problem file in the library, including subfolders, \item reads every \texttt{.pbu} record under the \texttt{roots} folders, including subfolders, \item writes the usage database \texttt{usage-db.tex} into the library, and \item typesets the matching problems, each headed by its path and title, with a table of its metadata and a table of every assignment that used it. \end{enumerate} \texttt{roots} is a comma list of folders, absolute or relative to the library; a leading \texttt{\textasciitilde} stands for the home folder. Records of assignments with \texttt{record = false} are ignored. \cs{problibcatalog}\oarg{filters} with no filters lists every problem. It may be used several times in one catalog, for example once per section with a different \texttt{folders} filter. The filters are: \medskip \noindent \begin{tabularx}{\linewidth}{@{}lX@{}} \toprule \texttt{folders} & Only problems under these library folders. \\ \texttt{tags}, \texttt{topics} & Only problems with at least one of the listed values (case-insensitive). \\ \texttt{difficulty} & Only problems with this difficulty (\texttt{2}) or a difficulty in this range (\texttt{1-3}). \\ \texttt{unusedsince} & Only problems not used on or after this date (\texttt{YYYY-MM-DD}). \\ \texttt{sort} & Order: \texttt{path} (default), \texttt{difficulty}, or \texttt{lastused}, which lists never-used and least recently used problems first. \\ \texttt{show} & What to show with each problem: any of \texttt{metadata}, \texttt{usage} and \texttt{solution}. Default: \texttt{\{metadata, usage\}}. \\ \bottomrule \end{tabularx} \medskip For instance, to find trigonometry problems in the calculus folder that were not used during the last year, least recently used first: \begin{lstlisting} \problibcatalog[folders = calculus, tags = {trig}, unusedsince = 2025-10-01, sort = lastused] \end{lstlisting} The example catalog in the repository's \texttt{library} folder shows the result for a small library. \DocInput{problib.dtx} \end{document} % % \fi % % \section{Implementation} % % \subsection{Messages and dependencies} % % \begin{macrocode} %<*package> \msg_new:nnn { problib } { no-exam } { The~problib~package~requires~the~exam~document~class. } \msg_new:nnn { problib } { no-luatex } { Catalog~mode~requires~LuaLaTeX. } \msg_new:nnn { problib } { not-catalog } { \token_to_str:N \problibcatalog \ requires~the~'catalog'~package~option. } \msg_new:nnn { problib } { missing } { Problem~file~'#1.tex'~not~found. } \msg_new:nnn { problib } { no-db } { No~usage~database~at~'#1'.~Compile~the~library~catalog~with~LuaLaTeX~ to~create~it. } \msg_new:nnn { problib } { bad-date } { Assignment~date~'#1'~is~not~YYYY-MM-DD;~reuse~warnings~disabled. } \msg_new:nnn { problib } { reuse } { Problem~'#1'~was~used~in~'#2'~on~#3. } \msg_new:nnn { problib } { bad-show } { Unknown~catalog~'show'~item~'#1'. } \IfClassLoadedTF { exam } { } { \msg_error:nn { problib } { no-exam } } \RequirePackage { xcolor } \RequirePackage { tabularx } % \end{macrocode} % % \subsection{Settings} % % Keys of \cs{problibsetup}, which are also the package options. % % \begin{macrocode} \str_new:N \l__problib_library_str \str_new:N \l__problib_roots_str \tl_new:N \l__problib_problemspace_tl \clist_new:N \l__problib_partspace_clist \clist_new:N \l__problib_subpartspace_clist \str_new:N \l__problib_spacestyle_str \bool_new:N \l__problib_showdata_bool \bool_new:N \l__problib_keyspace_bool \int_new:N \l__problib_warnreuse_int \bool_new:N \g__problib_catalog_bool \keys_define:nn { problib } { library .code:n = \__problib_set_library:n {#1} , roots .code:n = \str_set:Nn \l__problib_roots_str {#1} , problemspace .tl_set:N = \l__problib_problemspace_tl , partspace .clist_set:N = \l__problib_partspace_clist , subpartspace .clist_set:N = \l__problib_subpartspace_clist , spacestyle .choices:nn = { blank, lines, dottedlines, box, grid } { \str_set:NV \l__problib_spacestyle_str \l_keys_choice_tl } , spacestyle .initial:n = blank , showdata .bool_set:N = \l__problib_showdata_bool , keyspace .bool_set:N = \l__problib_keyspace_bool , warnreuse .int_set:N = \l__problib_warnreuse_int , catalog .bool_gset:N = \g__problib_catalog_bool , catalog .usage:n = load , } \cs_new_protected:Npn \__problib_set_library:n #1 { \str_set:Nn \l__problib_library_str {#1} \tl_trim_spaces:N \l__problib_library_str \str_if_empty:NF \l__problib_library_str { \str_if_eq:eeF { \str_item:Nn \l__problib_library_str { -1 } } { / } { \str_put_right:Nn \l__problib_library_str { / } } } } \NewDocumentCommand \problibsetup { m } { \keys_set:nn { problib } {#1} } % \end{macrocode} % % Options of \cs{useproblem}, which forward the spacing keys to the main % family inside the group of the command. % % \begin{macrocode} \tl_new:N \l__problib_points_tl \bool_new:N \l__problib_newpage_bool \bool_new:N \l__problib_active_bool \keys_define:nn { problib / use } { space .meta:nn = { problib } { problemspace = {#1} } , partspace .meta:nn = { problib } { partspace = {#1} } , subpartspace .meta:nn = { problib } { subpartspace = {#1} } , spacestyle .meta:nn = { problib } { spacestyle = {#1} } , points .tl_set:N = \l__problib_points_tl , newpage .bool_set:N = \l__problib_newpage_bool , newpage .default:n = true , } % \end{macrocode} % % \subsection{The assignment record} % % At the start of the document the assignment's details receive their % defaults, the record file \meta{jobname}\texttt{.pbu} is opened and its % header written, and the usage database is loaded. % % \begin{macrocode} \prop_new:N \g__problib_assign_prop \bool_new:N \g__problib_record_bool \str_new:N \g__problib_current_str \int_new:N \g__problib_today_int \iow_new:N \g__problib_iow \bool_gset_true:N \g__problib_record_bool \keys_define:nn { problib / assignment } { name .prop_gput:N = \g__problib_assign_prop , course .prop_gput:N = \g__problib_assign_prop , term .prop_gput:N = \g__problib_assign_prop , type .prop_gput:N = \g__problib_assign_prop , date .prop_gput:N = \g__problib_assign_prop , record .bool_gset:N = \g__problib_record_bool , } \NewDocumentCommand \problibassignment { m } { \keys_set:nn { problib / assignment } {#1} } \@onlypreamble \problibassignment \cs_new:Npn \__problib_two:n #1 { \int_compare:nNnT {#1} < { 10 } { 0 } \int_eval:n {#1} } \cs_new:Npn \__problib_field:n #1 { #1 = { \prop_item:Nn \g__problib_assign_prop {#1} } , } \cs_new_protected:Npn \__problib_begin_assignment: { \prop_if_in:NnF \g__problib_assign_prop { name } { \prop_gput:NnV \g__problib_assign_prop { name } \c_sys_jobname_str } \prop_if_in:NnF \g__problib_assign_prop { date } { \prop_gput:Nne \g__problib_assign_prop { date } { \int_use:N \c_sys_year_int - \__problib_two:n { \c_sys_month_int } - \__problib_two:n { \c_sys_day_int } } } \str_gset:Ne \g__problib_current_str { \prop_item:Nn \g__problib_assign_prop { name } | \prop_item:Nn \g__problib_assign_prop { course } | \prop_item:Nn \g__problib_assign_prop { term } } \__problib_set_today:e { \prop_item:Nn \g__problib_assign_prop { date } } \iow_open:Nn \g__problib_iow { \c_sys_jobname_str .pbu } \iow_now:Ne \g__problib_iow { \token_to_str:N \problibrecord { \__problib_field:n { name } \__problib_field:n { course } \__problib_field:n { term } \__problib_field:n { type } \__problib_field:n { date } record = { \bool_if:NTF \g__problib_record_bool { true } { false } } } } \__problib_load_db:e { \l__problib_library_str usage-db.tex } } % \end{macrocode} % % \subsection{Dates} % % Dates are converted to a day count (a Julian day number) so that reuse % windows can be measured in days. % % \begin{macrocode} \regex_const:Nn \c__problib_date_regex { \A \d{4} - (0?[1-9]|1[0-2]) - (0?[1-9]|[12]\d|3[01]) \Z } \cs_new:Npn \__problib_days:n #1 { \__problib_days:w #1 \q_stop } \cs_new:Npn \__problib_days:w #1 - #2 - #3 \q_stop { \__problib_days:ffn { \int_eval:n { #1 + 4800 - \int_div_truncate:nn { 14 - #2 } { 12 } } } { \int_eval:n { #2 + 12 * \int_div_truncate:nn { 14 - #2 } { 12 } - 3 } } {#3} } \cs_new:Npn \__problib_days:nnn #1#2#3 { \int_eval:n { #3 + \int_div_truncate:nn { 153 * #2 + 2 } { 5 } + 365 * #1 + \int_div_truncate:nn {#1} { 4 } - \int_div_truncate:nn {#1} { 100 } + \int_div_truncate:nn {#1} { 400 } } } \cs_generate_variant:Nn \__problib_days:nnn { ff } \cs_new_protected:Npn \__problib_set_today:n #1 { \regex_match:NnTF \c__problib_date_regex {#1} { \int_gset:Nn \g__problib_today_int { \__problib_days:n {#1} } } { \msg_warning:nnn { problib } { bad-date } {#1} \int_set_eq:NN \l__problib_warnreuse_int \c_zero_int } } \cs_generate_variant:Nn \__problib_set_today:n { e } % \end{macrocode} % % \subsection{The usage database} % % \texttt{usage-db.tex} holds one line per problem, % \cs{problibusage}\marg{path}\marg{entries}, where each entry is % \cs{problibentry}\marg{date}\marg{name}\marg{course}\marg{term}\marg{type}\marg{file}, % newest first. It is read with the special characters of file paths made % harmless, and each problem's entries are stored unexpanded so that % \cs{problibentry} can be given different meanings when they are used. % % \begin{macrocode} \prop_new:N \g__problib_usage_prop \bool_new:N \g__problib_db_bool \cs_new_protected:Npn \__problib_load_db:n #1 { \file_if_exist:nTF {#1} { \group_begin: \clist_map_inline:nn { \#, \$, \%, \&, \^, \_, \~ } { \char_set_catcode_other:N ##1 } \cs_set_protected:Npn \problibusage ##1##2 { \prop_gput:Nnn \g__problib_usage_prop {##1} {##2} } \file_input:n {#1} \group_end: \bool_gset_true:N \g__problib_db_bool } { \msg_warning:nnn { problib } { no-db } {#1} } } \cs_generate_variant:Nn \__problib_load_db:n { e } % \end{macrocode} % % For the current problem, fetch its entries and find the most recent one that % does not belong to the assignment being compiled. % % \begin{macrocode} \str_new:N \l__problib_path_str \tl_new:N \l__problib_uses_tl \tl_new:N \l__problib_last_tl \cs_new_protected:Npn \__problib_scan_usage: { \tl_clear:N \l__problib_last_tl \prop_get:NVNF \g__problib_usage_prop \l__problib_path_str \l__problib_uses_tl { \tl_clear:N \l__problib_uses_tl } \cs_set_protected:Npn \problibentry ##1##2##3##4##5##6 { \tl_if_empty:NT \l__problib_last_tl { \str_if_eq:eeF { \tl_to_str:n { ##2 | ##3 | ##4 } } { \g__problib_current_str } { \tl_set:Nn \l__problib_last_tl { {##1} {##2} {##3} {##4} } } } } \l__problib_uses_tl } \cs_new_protected:Npn \__problib_check_reuse: { \bool_lazy_and:nnT { \int_compare_p:nNn \l__problib_warnreuse_int > \c_zero_int } { ! \tl_if_empty_p:N \l__problib_last_tl } { \exp_after:wN \__problib_check_reuse:nnnn \l__problib_last_tl } } \cs_new_protected:Npn \__problib_check_reuse:nnnn #1#2#3#4 { \regex_match:NnT \c__problib_date_regex {#1} { \int_compare:nNnT { \g__problib_today_int - \__problib_days:n {#1} } < \l__problib_warnreuse_int { \msg_warning:nneee { problib } { reuse } { \l__problib_path_str } { \exp_not:n {#2} \tl_if_blank:nF {#4} { ~( \exp_not:n {#4} ) } } {#1} } } } % \end{macrocode} % % \subsection{Answer space} % % A space is skipped when it is blank, zero, or when an answer key is being % printed without \texttt{keyspace}. Values starting with a control sequence % (\cs{fill}, \cs{stretch}) are always used. % % \begin{macrocode} \cs_new_protected:Npn \__problib_space:n #1 { \bool_lazy_or:nnF { \tl_if_blank_p:n {#1} } { \bool_lazy_and_p:nn { \legacy_if_p:n { printanswers } } { ! \l__problib_keyspace_bool } } { \tl_if_head_eq_catcode:nNTF {#1} \scan_stop: { \__problib_fill:n {#1} } { \dim_compare:nNnF {#1} = \c_zero_dim { \__problib_fill:n {#1} } } } } \cs_generate_variant:Nn \__problib_space:n { V, e } \cs_new_protected:Npn \__problib_fill:n #1 { \par \use:c { __problib_fill_ \l__problib_spacestyle_str :n } {#1} } \cs_new_protected:Npn \__problib_fill_blank:n { \vspace } \cs_new_protected:Npn \__problib_fill_lines:n { \fillwithlines } \cs_new_protected:Npn \__problib_fill_dottedlines:n { \fillwithdottedlines } \cs_new_protected:Npn \__problib_fill_box:n { \makeemptybox } \cs_new_protected:Npn \__problib_fill_grid:n { \fillwithgrid } \NewDocumentCommand \problemspace { o m } { \group_begin: \IfValueT {#1} { \keys_set:nn { problib } { spacestyle = #1 } } \__problib_space:n {#2} \group_end: } % \end{macrocode} % % \texttt{exam} defines \cs{part} inside the \texttt{parts} environment, so % \pl{} wraps it (and \cs{subpart}) right after the environment starts, but % only within problems inserted by \cs{useproblem}. Each part inserts the % space of the previous one; the end of the environment inserts the space of % the last. Parts inside a \texttt{solution} are not counted. % % \begin{macrocode} \int_new:N \g__problib_part_int \int_new:N \g__problib_subpart_int \bool_new:N \g__problib_has_sub_bool \cs_new:Npn \__problib_item:Nn #1#2 { \clist_item:Nn #1 { \int_min:nn {#2} { \clist_count:N #1 } } } \cs_new_protected:Npn \__problib_unless_solution:n #1 { \legacy_if:nF { @insolution } {#1} } \cs_new_protected:Npn \__problib_part_space: { \bool_if:NF \g__problib_has_sub_bool { \__problib_space:e { \__problib_item:Nn \l__problib_partspace_clist \g__problib_part_int } } } \cs_new_protected:Npn \__problib_subpart_space: { \__problib_space:e { \__problib_item:Nn \l__problib_subpartspace_clist \g__problib_subpart_int } } \cs_new_protected:Npn \__problib_next_part: { \__problib_unless_solution:n { \int_compare:nNnT \g__problib_part_int > \c_zero_int { \__problib_part_space: } \int_gincr:N \g__problib_part_int \int_gzero:N \g__problib_subpart_int \bool_gset_false:N \g__problib_has_sub_bool } } \cs_new_protected:Npn \__problib_next_subpart: { \__problib_unless_solution:n { \int_compare:nNnT \g__problib_subpart_int > \c_zero_int { \__problib_subpart_space: } \int_gincr:N \g__problib_subpart_int } } \cs_new_protected:Npn \__problib_wrap:NNN #1#2#3 { \cs_set_eq:cN { __problib_ \cs_to_str:N #1 :w } #1 \cs_set_eq:cN { __problib_ \cs_to_str:N #2 :w } #2 \cs_set_protected:Npe #1 { \exp_not:N #3 \exp_not:c { __problib_ \cs_to_str:N #1 :w } } \cs_set_protected:Npe #2 { \exp_not:N #3 \exp_not:c { __problib_ \cs_to_str:N #2 :w } } } \AddToHook { cmd/parts/after } { \bool_if:NT \l__problib_active_bool { \__problib_wrap:NNN \part \bonuspart \__problib_next_part: } } \AddToHook { cmd/subparts/after } { \bool_if:NT \l__problib_active_bool { \__problib_wrap:NNN \subpart \bonussubpart \__problib_next_subpart: } } \AddToHook { env/parts/end } { \bool_if:NT \l__problib_active_bool { \__problib_unless_solution:n { \int_compare:nNnT \g__problib_part_int > \c_zero_int { \__problib_part_space: } } } } \AddToHook { env/subparts/end } { \bool_if:NT \l__problib_active_bool { \__problib_unless_solution:n { \int_compare:nNnT \g__problib_subpart_int > \c_zero_int { \__problib_subpart_space: } \bool_gset_true:N \g__problib_has_sub_bool } } } % \end{macrocode} % % \subsection{Problems} % % \cs{useproblem} normalizes the path, records the use and inputs the file % inside a group, so its options stay local to the problem. The % \texttt{problem} environment stores the metadata, issues \cs{question} and % adds the question's space when it had no parts. % % \begin{macrocode} \prop_new:N \l__problib_meta_prop \regex_const:Nn \c__problib_ext_regex { \A (\./)+ | \.tex \Z } \cs_new_protected:Npn \__problib_meta:n #1 { \prop_put:Nnn \l__problib_meta_prop {#1} { } } \cs_new_protected:Npn \__problib_meta:nn #1#2 { \prop_put:Nnn \l__problib_meta_prop {#1} {#2} } \NewExpandableDocumentCommand \problemmeta { m } { \prop_item:Nn \l__problib_meta_prop {#1} } \NewDocumentCommand \useproblem { O{} m } { \group_begin: \bool_set_true:N \l__problib_active_bool \keys_set:nn { problib / use } {#1} \str_set:Nn \l__problib_path_str {#2} \tl_trim_spaces:N \l__problib_path_str \regex_replace_all:NnN \c__problib_ext_regex { } \l__problib_path_str \file_if_exist:nTF { \l__problib_library_str \l__problib_path_str .tex } { \__problib_scan_usage: \bool_if:NF \g__problib_catalog_bool { \iow_now:Ne \g__problib_iow { \token_to_str:N \problibuse { \l__problib_path_str } } \__problib_check_reuse: } \file_input:n { \l__problib_library_str \l__problib_path_str .tex } } { \msg_error:nne { problib } { missing } { \l__problib_path_str } } \group_end: } \NewDocumentEnvironment { problem } { O{} } { \prop_clear:N \l__problib_meta_prop \keyval_parse:nnn \__problib_meta:n \__problib_meta:nn {#1} \int_gzero:N \g__problib_part_int \bool_if:NT \l__problib_newpage_bool { \newpage } \tl_if_empty:NT \l__problib_points_tl { \prop_get:NnNF \l__problib_meta_prop { points } \l__problib_points_tl { } } \tl_if_blank:VTF \l__problib_points_tl { \question } { \use:e { \exp_not:N \question [ \l__problib_points_tl ] } } \bool_if:NTF \g__problib_catalog_bool { \__problib_catalog_note: } { \bool_if:NT \l__problib_showdata_bool { \__problib_data_note: } } } { \int_compare:nNnT \g__problib_part_int = \c_zero_int { \__problib_space:V \l__problib_problemspace_tl } } % \end{macrocode} % % \subsection{Notes and tables} % % The heading, metadata table and usage table shared by \texttt{showdata} notes % and the catalog. % % \begin{macrocode} \tl_new:N \l__problib_rows_tl \int_new:N \l__problib_cell_int \bool_new:N \l__problib_show_metadata_bool \bool_new:N \l__problib_show_usage_bool \bool_new:N \l__problib_show_solution_bool \cs_new_protected:Npn \__problib_meta_key: { \leavevmode \sffamily \color { gray } } \cs_new_protected:Npn \__problib_heading: { \texttt { \l__problib_path_str } \prop_get:NnNT \l__problib_meta_prop { title } \l__problib_rows_tl { \quad \textit { \mdseries \l__problib_rows_tl } } \par } % \end{macrocode} % % Metadata other than the title, two key/value pairs per row. % % \begin{macrocode} \cs_new_protected:Npn \__problib_meta_table: { \tl_clear:N \l__problib_rows_tl \int_zero:N \l__problib_cell_int \prop_map_inline:Nn \l__problib_meta_prop { \str_if_eq:nnF {##1} { title } { \int_incr:N \l__problib_cell_int \tl_put_right:Nn \l__problib_rows_tl { ##1 & ##2 } \tl_put_right:Ne \l__problib_rows_tl { \int_if_odd:nTF \l__problib_cell_int { & } { \exp_not:N \\ } } } } \tl_if_empty:NF \l__problib_rows_tl { \begin{tabularx} { \linewidth } { @{} >{ \__problib_meta_key: } l >{ \raggedright \arraybackslash } X @{ \hspace { 1.5em } } >{ \__problib_meta_key: } l >{ \raggedright \arraybackslash } X @{} } \l__problib_rows_tl \end{tabularx} \par } } % \end{macrocode} % % Uses of the current problem, newest first, excluding the assignment being % compiled. % % \begin{macrocode} \cs_new_protected:Npn \__problib_usage_table: { \tl_clear:N \l__problib_rows_tl \cs_set_protected:Npn \problibentry ##1##2##3##4##5##6 { \str_if_eq:eeF { \tl_to_str:n { ##2 | ##3 | ##4 } } { \g__problib_current_str } { \tl_put_right:Nn \l__problib_rows_tl { ##1 & ##3 & ##4 & ##5 & ##2 \\ } } } \l__problib_uses_tl \tl_if_empty:NTF \l__problib_rows_tl { { \__problib_meta_key: Not~used~before. } \par } { \begin{tabularx} { \linewidth } { @{} l l l l >{ \raggedright \arraybackslash } X @{} } \__problib_meta_key: date & \__problib_meta_key: course & \__problib_meta_key: term & \__problib_meta_key: type & \__problib_meta_key: assignment \\ \l__problib_rows_tl \end{tabularx} \par } } \cs_new_protected:Npn \__problib_data_note: { \leavevmode \group_begin: \footnotesize \color { gray } \__problib_heading: \__problib_usage_table: \group_end: \smallskip } \cs_new_protected:Npn \__problib_catalog_note: { { \bfseries \__problib_heading: } \group_begin: \footnotesize \bool_if:NT \l__problib_show_metadata_bool { \__problib_meta_table: } \bool_lazy_and:nnT \l__problib_show_metadata_bool \l__problib_show_usage_bool { \smallskip } \bool_if:NT \l__problib_show_usage_bool { \__problib_usage_table: } \group_end: \smallskip } % \end{macrocode} % % \subsection{The catalog} % % \cs{problibcatalog} passes its filters to Lua, which scans the library and % the roots, writes the usage database and returns the matching paths. The % problems are then inserted with \cs{useproblem}, without answer space. % % \begin{macrocode} \str_new:N \l__problib_cat_folders_str \str_new:N \l__problib_cat_tags_str \str_new:N \l__problib_cat_topics_str \str_new:N \l__problib_cat_difficulty_str \str_new:N \l__problib_cat_unusedsince_str \str_new:N \l__problib_cat_sort_str \clist_new:N \l__problib_cat_clist \cs_new_protected:Npn \__problib_set_show:n #1 { \clist_map_inline:nn { metadata, usage, solution } { \bool_set_false:c { l__problib_show_ ##1 _bool } } \clist_map_inline:nn {#1} { \bool_if_exist:cTF { l__problib_show_ ##1 _bool } { \bool_set_true:c { l__problib_show_ ##1 _bool } } { \msg_error:nnn { problib } { bad-show } {##1} } } } \keys_define:nn { problib / filter } { folders .str_set:N = \l__problib_cat_folders_str , tags .str_set:N = \l__problib_cat_tags_str , topics .str_set:N = \l__problib_cat_topics_str , difficulty .str_set:N = \l__problib_cat_difficulty_str , unusedsince .str_set:N = \l__problib_cat_unusedsince_str , sort .choices:nn = { path, difficulty, lastused } { \str_set:NV \l__problib_cat_sort_str \l_keys_choice_tl } , sort .initial:n = path , show .code:n = \__problib_set_show:n {#1} , show .initial:n = { metadata, usage } , } \cs_new:Npn \__problib_lua_opt:nN #1#2 { #1 = " \lua_escape:e {#2} " , } \NewDocumentCommand \problibcatalog { O{} } { \bool_if:NTF \g__problib_catalog_bool { \__problib_catalog:n {#1} } { \msg_error:nn { problib } { not-catalog } } } \cs_new_protected:Npn \__problib_catalog:n #1 { \group_begin: \keys_set:nn { problib / filter } {#1} \clist_set:Ne \l__problib_cat_clist { \lua_now:e { problib.catalog { \__problib_lua_opt:nN { roots } \l__problib_roots_str \__problib_lua_opt:nN { folders } \l__problib_cat_folders_str \__problib_lua_opt:nN { tags } \l__problib_cat_tags_str \__problib_lua_opt:nN { topics } \l__problib_cat_topics_str \__problib_lua_opt:nN { difficulty } \l__problib_cat_difficulty_str \__problib_lua_opt:nN { unusedsince } \l__problib_cat_unusedsince_str \__problib_lua_opt:nN { sort } \l__problib_cat_sort_str } } } \bool_if:NF \g__problib_db_bool { \__problib_load_db:n { usage-db.tex } } \str_clear:N \l__problib_library_str \tl_clear:N \l__problib_problemspace_tl \clist_clear:N \l__problib_partspace_clist \clist_clear:N \l__problib_subpartspace_clist \bool_if:NTF \l__problib_show_solution_bool { \legacy_if_set_true:n { printanswers } } { \legacy_if_set_false:n { printanswers } } \clist_if_empty:NTF \l__problib_cat_clist { \emph { No~problems~match. } \par } { \begin { questions } \clist_map_inline:Nn \l__problib_cat_clist { \useproblem {##1} } \end { questions } } \group_end: } % \end{macrocode} % % \subsection{Options and mode} % % In catalog mode the Lua code at the end of this file is loaded: \TeX{} stops % reading at \cs{file\_input\_stop:}, and Lua reads the text between the % \texttt{--[[problib.lua]]} markers from the same file. Otherwise the record % is opened at the start of the document. % % \begin{macrocode} \ProcessKeyOptions [ problib ] \bool_if:NTF \g__problib_catalog_bool { \sys_if_engine_luatex:TF { \lua_now:n { local~f=io.open(kpse.find_file("problib.sty")); local~src=f:read("a"); f:close(); local~_,s=src:find("--[[problib"..".lua]]",1,true); local~t=s~and~src:find("--[[/problib"..".lua]]",s,true); assert(t,"problib:~Lua~code~not~found~in~problib.sty"); problib=load(src:sub(s+1,t-1),"=problib.lua")(); } } { \msg_error:nn { problib } { no-luatex } } } { \AtBeginDocument { \__problib_begin_assignment: } \AtEndDocument { \iow_close:N \g__problib_iow } } \file_input_stop: % \end{macrocode} % % \subsection{Lua code} % % The library is every \texttt{.tex} file below the catalog's folder that % contains a \texttt{problem} environment and no \cs{documentclass}. Metadata is % read from the environment's optional argument. Hidden files and symbolic % links are skipped. % % \begin{macrocode} --[[problib.lua]] local problib = {} local function read_file(path) local f = io.open(path, "r") if not f then return nil end local s = f:read("a") f:close() return s end local function warn(msg) texio.write_nl("term and log", "Package problib Warning: " .. msg) end local function trim(s) s = s:match("^%s*(.-)%s*$") if s:match("^%b{}$") then s = s:sub(2, -2):match("^%s*(.-)%s*$") end return s end -- Split on commas outside braces. local function split(s) local items, depth, start = {}, 0, 1 for i = 1, #s do local c = s:byte(i) if c == 123 then depth = depth + 1 elseif c == 125 then depth = depth - 1 elseif c == 44 and depth == 0 then items[#items + 1] = s:sub(start, i - 1) start = i + 1 end end items[#items + 1] = s:sub(start) return items end local function set_of(s) local t = {} for _, item in ipairs(split(s or "")) do item = trim(item):lower() if item ~= "" then t[item] = true end end return t end local function walk(dir, ext, out, rel) local ok, iter, state = pcall(lfs.dir, dir) if not ok then return out end for name in iter, state do if name:sub(1, 1) ~= "." then local full = dir .. "/" .. name local mode = lfs.symlinkattributes(full, "mode") if mode == "directory" then walk(full, ext, out, rel .. name .. "/") elseif mode == "file" and name:sub(-#ext) == ext then out[#out + 1] = { full = full, rel = rel .. name:sub(1, -#ext - 1) } end end end return out end -- Returns the metadata table of a problem file, or nil if it is not one. local function parse_problem(text) text = ("\n" .. text):gsub("([^\\])%%[^\n]*", "%1") if text:find("\\documentclass") then return nil end local _, e = text:find("\\begin%s*{problem}%s*") if not e then return nil end local meta = {} if text:sub(e + 1, e + 1) ~= "[" then return meta end local depth = 0 for i = e + 2, #text do local c = text:byte(i) if c == 123 then depth = depth + 1 elseif c == 125 then depth = depth - 1 elseif c == 93 and depth == 0 then for _, item in ipairs(split(text:sub(e + 2, i - 1))) do local k, v = item:match("^%s*([^=]-)%s*=(.*)$") if k and k ~= "" then meta[k] = trim(v) end end break end end return meta end % \end{macrocode} % % Usage records are collected from every \texttt{.pbu} file below the roots. % Identical uses (the same problem, date, name, course and term, as happens % when an assignment folder is copied) are counted once. % % \begin{macrocode} local function collect_usage(roots) local usage, seen = {}, {} local home = os.getenv("HOME") or "~" for _, root in ipairs(split(roots)) do root = trim(root):gsub("^~", home):gsub("/$", "") if root ~= "" then if lfs.attributes(root, "mode") ~= "directory" then warn("usage root '" .. root .. "' not found") end for _, f in ipairs(walk(root, ".pbu", {}, "")) do local s = read_file(f.full) local rec = s and s:match("\\problibrecord%s*(%b{})") if rec then local r = {} for k, v in rec:sub(2, -2):gmatch("(%w+)%s*=%s*(%b{})") do r[k] = v:sub(2, -2) end if r.record ~= "false" then local entry = { r.date or "", r.name or "", r.course or "", r.term or "", r.type or "", f.full } local id = table.concat(entry, "\0", 1, 4) for p in s:gmatch("\\problibuse%s*(%b{})") do p = p:sub(2, -2) local key = p .. "\0" .. id if not seen[key] then seen[key] = true usage[p] = usage[p] or {} table.insert(usage[p], entry) end end end end end end end for _, uses in pairs(usage) do table.sort(uses, function(a, b) if a[1] ~= b[1] then return a[1] > b[1] end return a[2] < b[2] end) end return usage end local function write_db(usage, path) local paths = {} for p in pairs(usage) do paths[#paths + 1] = p end table.sort(paths) local lines = {} for i, p in ipairs(paths) do local entries = {} for j, u in ipairs(usage[p]) do entries[j] = "\\problibentry{" .. table.concat(u, "}{") .. "}" end lines[i] = "\\problibusage{" .. p .. "}{" .. table.concat(entries) .. "}" end local f = io.open(path, "w") if not f then warn("cannot write '" .. path .. "'") return end f:write(table.concat(lines, "\n"), "\n") f:close() end local cache local function scan(roots) if cache then return cache end local problems = {} for _, f in ipairs(walk(lfs.currentdir(), ".tex", {}, "")) do local text = read_file(f.full) local meta = text and parse_problem(text) if meta then problems[#problems + 1] = { path = f.rel, meta = meta } end end local usage = collect_usage(roots) write_db(usage, "usage-db.tex") cache = { problems = problems, usage = usage } return cache end % \end{macrocode} % % Filtering and sorting. The result is returned to \TeX{} as a comma list of % paths with string catcodes. % % \begin{macrocode} local function any(want, have) for w in pairs(want) do if have[w] then return true end end return false end function problib.catalog(opts) local data = scan(opts.roots) local usage = data.usage local folders = {} for _, f in ipairs(split(opts.folders)) do f = trim(f):gsub("/$", "") if f ~= "" then folders[#folders + 1] = f end end local tags, topics = set_of(opts.tags), set_of(opts.topics) local lo, hi if opts.difficulty ~= "" then lo, hi = opts.difficulty:match("^%s*(%d+)%s*%-?%s*(%d*)%s*$") if lo then lo, hi = tonumber(lo), tonumber(hi) or tonumber(lo) else warn("ignoring difficulty filter '" .. opts.difficulty .. "'") end end local since = trim(opts.unusedsince) local function last(p) local u = usage[p.path] return u and u[1][1] or "" end local function keep(p) if #folders > 0 then local hit = false for _, f in ipairs(folders) do if p.path == f or p.path:sub(1, #f + 1) == f .. "/" then hit = true break end end if not hit then return false end end if next(tags) and not any(tags, set_of(p.meta.tags)) then return false end if next(topics) and not any(topics, set_of(p.meta.topics)) then return false end if lo then local d = tonumber(p.meta.difficulty) if not d or d < lo or d > hi then return false end end if since ~= "" and last(p) >= since then return false end return true end local result = {} for _, p in ipairs(data.problems) do if keep(p) then result[#result + 1] = p end end local key if opts.sort == "difficulty" then key = function(p) return tonumber(p.meta.difficulty) or math.huge end elseif opts.sort == "lastused" then key = last end table.sort(result, function(a, b) if key then local x, y = key(a), key(b) if x ~= y then return x < y end end return a.path < b.path end) local paths = {} for i, p in ipairs(result) do paths[i] = p.path end tex.sprint(-2, table.concat(paths, ",")) end return problib --[[/problib.lua]] % % \end{macrocode} % % \Finale \endinput