Compare commits
52
Commits
f1d26f01e2
...
dev
+3
-1
@@ -4,4 +4,6 @@ POSTGRES_USER=fabled
|
|||||||
POSTGRES_PASSWORD=fabled
|
POSTGRES_PASSWORD=fabled
|
||||||
POSTGRES_DB=grimoire
|
POSTGRES_DB=grimoire
|
||||||
# PGDATA_VOLUME=grimoire_pgdata
|
# PGDATA_VOLUME=grimoire_pgdata
|
||||||
SECRET_KEY=dev-secret-change-me
|
# Grimoire keeps its own SECRET_KEY in the /data volume. Set one here only to
|
||||||
|
# use a key you manage yourself; it then wins over the kept one.
|
||||||
|
# SECRET_KEY=
|
||||||
|
|||||||
@@ -56,5 +56,9 @@ ENV APP_BUILD_KEY=$BUILD_KEY
|
|||||||
ENV APP_CHANNEL=$BUILD_CHANNEL
|
ENV APP_CHANNEL=$BUILD_CHANNEL
|
||||||
ENV APP_COMMIT=$BUILD_COMMIT
|
ENV APP_COMMIT=$BUILD_COMMIT
|
||||||
|
|
||||||
|
# The instance's own SECRET_KEY, the model cache and diagnostics live here.
|
||||||
|
# Mount a volume on it so they outlive the container.
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'grimoire.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'grimoire.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -3,22 +3,22 @@
|
|||||||
# --- Docker ---
|
# --- Docker ---
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker compose build
|
docker compose -f docker-compose.dev.yml build
|
||||||
|
|
||||||
up:
|
up:
|
||||||
docker compose up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
down:
|
down:
|
||||||
docker compose down
|
docker compose -f docker-compose.dev.yml down
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
docker compose logs -f
|
docker compose -f docker-compose.dev.yml logs -f
|
||||||
|
|
||||||
health:
|
health:
|
||||||
curl -s http://localhost:5000/api/health
|
curl -s http://localhost:5000/api/health
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
docker compose exec app alembic upgrade head
|
docker compose -f docker-compose.dev.yml exec app alembic upgrade head
|
||||||
|
|
||||||
# --- Local dev (requires: uv sync --extra dev and cd frontend && npm ci) ---
|
# --- Local dev (requires: uv sync --extra dev and cd frontend && npm ci) ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,150 @@
|
|||||||
# Fabled Grimoire
|
<p align="center">
|
||||||
|
<img src="frontend/public/favicon.svg" width="96" alt="">
|
||||||
|
</p>
|
||||||
|
|
||||||
A self-hosted work system-of-record for software projects, built to be driven by Claude Code. Notes, tasks, issues, projects, milestones, rules, and stored processes — reachable from Claude via a built-in MCP endpoint and a bundled Claude Code plugin, with a clean web UI for humans. No in-app LLM; Claude is the sole assistant.
|
<h1 align="center">Fabled Grimoire</h1>
|
||||||
|
|
||||||
|
<p align="center">A self-hosted system of record for the work you do with Claude Code.</p>
|
||||||
|
|
||||||
|
Claude records what it learned, decided and built in Grimoire, and that record reaches the next session, so a solution is recalled instead of reinvented. Notes, tasks, plans, rules, lessons and reusable code live in one place. Claude reaches them through a built-in MCP endpoint and the bundled Claude Code plugin, and people read and edit the same records in the web UI. There is no language model inside Grimoire: Claude is the only assistant.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
You need Docker with Compose. Save this as `docker-compose.yml` (it is the [`docker-compose.yml`](docker-compose.yml) in this repository), then run `docker compose up -d` beside it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Fabled Grimoire: the app and its database.
|
||||||
|
# docker compose up -d
|
||||||
|
# Then open http://localhost:5000. The first account registered becomes admin.
|
||||||
|
#
|
||||||
|
# Keep both volumes. app_data holds the instance's own SECRET_KEY, which seals
|
||||||
|
# saved credentials; pgdata holds everything else.
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: git.fabledsword.com/bvandeusen/fabledgrimoire:latest
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${POSTGRES_PASSWORD:-grimoire}@db:5432/${POSTGRES_DB:-grimoire}"
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||||
|
# Port 5000 is published straight to clients, so no proxy is trusted.
|
||||||
|
# Behind a reverse proxy, set 1 (one per proxy) and BASE_URL:
|
||||||
|
TRUSTED_PROXY_HOPS: "0"
|
||||||
|
# BASE_URL: "https://grimoire.example.com"
|
||||||
|
volumes:
|
||||||
|
- app_data:/data
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
# Postgres 17 with the vector extension Grimoire needs.
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
stop_grace_period: 120s
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-grimoire}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-grimoire}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-grimoire}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app_data:
|
||||||
|
pgdata:
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:5000`. The first account you register becomes the admin.
|
||||||
|
|
||||||
|
Nothing has to be set first. On its first start Grimoire makes its own `SECRET_KEY` and keeps it in the `app_data` volume, so restarts, reboots and updates all use the same key.
|
||||||
|
|
||||||
|
To connect Claude, create an API key under **Settings → MCP Access**, then in Claude Code:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledGrimoire.git
|
||||||
|
/plugin install grimoire@grimoire-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin asks for your Grimoire URL and the API key. Settings → MCP Access shows the exact commands for your instance.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Everything is optional. Set these in the environment or in a `.env` file beside the compose file:
|
||||||
|
|
||||||
|
| Variable | When | What it does |
|
||||||
|
|----------|------|--------------|
|
||||||
|
| `BASE_URL` | Behind a proxy | The public URL, used in email links and SSO redirects |
|
||||||
|
| `TRUSTED_PROXY_HOPS` | Rarely | The starting count of reverse proxies in front of Grimoire (default `1`; the compose file above sets `0` because it publishes the port directly). Check and change it in **Settings → Config → Reverse proxy**, which shows what each count reads for your own request; behind Cloudflare it is usually `2` |
|
||||||
|
| `POSTGRES_PASSWORD` | Before the first start | The database password (defaults to `grimoire`; the database is reachable only inside the compose network). Postgres takes it once, when the volume is created |
|
||||||
|
| `SECRET_KEY` | Rarely | A key you manage yourself, instead of the one Grimoire keeps. It wins over the kept key; remove it to go back |
|
||||||
|
|
||||||
|
Everything else (registration, email, SSO, git forge connections, retrieval tuning, backups) is in **Settings**, or listed in [Configuration](docs/configuration.md).
|
||||||
|
|
||||||
|
## Setup pitfalls
|
||||||
|
|
||||||
|
- **Keep both volumes.** `pgdata` holds everything Grimoire stores. `app_data` holds the instance's key, and forge tokens and the backup bucket's secret are sealed under it. If the key is lost, Settings asks you to enter those credentials again; nothing else is lost.
|
||||||
|
- **The embedding model downloads on first use**, into the `app_data` volume. Without internet access, semantic search falls back to keyword search until the model is in place.
|
||||||
|
- **Behind a reverse proxy, turn off response buffering** for `/mcp`, because MCP responses stream. [Configuration](docs/configuration.md#behind-a-reverse-proxy) has an Nginx example.
|
||||||
|
|
||||||
|
## Updating and backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Database migrations run when the app starts, and the key stays in its volume. Claude Code updates the plugin from its marketplace; run `/reload-plugins` after an update.
|
||||||
|
|
||||||
|
**Settings → Data → Scheduled backups** writes a daily backup of the database to a folder or an S3-compatible bucket, and Settings → Data → Restore from Backup rolls the instance back to one, or brings a new instance back from it. See [Backups](docs/backups.md).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
Notes, tasks and issues; projects with milestones as plans; rules that reach Claude when its work matches them or at a moment of work; lessons, snippets of prior art and a ledger of the code they account for; design systems; ideas shared across projects on the same platform; and stored processes. Semantic search feeds both Claude's recall and the plugin's context injection. A knowledge graph, sharing with users and groups, and scheduled backups round it out. See [Features](docs/features.md).
|
**Working with Claude**
|
||||||
|
- Claude enters a project and gets a short primer: its goal, current plans, open tasks and areas.
|
||||||
|
- Your rules reach Claude when its work matches them, or at a moment of work such as finishing a task or pushing code.
|
||||||
|
- Before Claude writes a file or runs a command, the plugin shows it the rules, lessons and reusable code that may apply.
|
||||||
|
- Stored procedures, such as a drift audit, become Claude Code skills.
|
||||||
|
|
||||||
## Quick Start
|
**Recording the work**
|
||||||
|
- Notes in a Markdown editor, with wikilinks, tags and version history.
|
||||||
|
- Tasks, issues and time-boxed investigations, with work logs that the next session reads first.
|
||||||
|
- Plans as milestones: a design plus ordered steps.
|
||||||
|
- Lessons learned the hard way, linked to the rules they illustrate.
|
||||||
|
- Duplicate checks, so a new record that matches an existing one updates it instead.
|
||||||
|
|
||||||
**Prerequisites:** Docker and Docker Compose. No GPU or local model needed — Claude is the sole assistant, reached over MCP.
|
**Reusing code**
|
||||||
|
- Snippets of reusable code, each with when to reach for it and where it lives in the repo.
|
||||||
|
- With a git forge connected, Grimoire tracks every function and style in your repos and shows which ones are still unaccounted for.
|
||||||
|
- Ideas shared across projects on the same platform, with each project's answer recorded.
|
||||||
|
- Design systems with tokens, inheritance and a generated stylesheet.
|
||||||
|
|
||||||
Download [`docker-compose.quickstart.yml`](docker-compose.quickstart.yml) from this repo, then:
|
**For people**
|
||||||
|
- A web UI to browse, search and edit everything, plus a graph of how notes connect.
|
||||||
|
- Sharing with users and groups, as viewer, editor or admin.
|
||||||
|
- Email and password sign-in, optional SSO through OIDC, and invite links.
|
||||||
|
- Personal export to Markdown or JSON, and admin backup and restore.
|
||||||
|
|
||||||
|
The full list is in [Features](docs/features.md).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| What you see | Why | Fix |
|
||||||
|
|--------------|-----|-----|
|
||||||
|
| `SECRET_KEY is set to the public placeholder` at startup | `SECRET_KEY` is `dev-secret-change-me` or `change-me-in-production` | Remove `SECRET_KEY`, or set a real one |
|
||||||
|
| Settings asks for forge tokens or the backup secret again | The instance's key changed: the `app_data` volume was replaced, or `SECRET_KEY` was set or removed | Enter them again, or restore the previous key |
|
||||||
|
| `Can't locate revision identified by '…'` at startup | An older image is running against a database a newer one has already migrated | Run the newer image; migrations only move forward |
|
||||||
|
| The plugin's tools are missing in Claude Code | The plugin isn't installed, or Claude Code hasn't reloaded it | `/plugin` to install or update it, then `/reload-plugins` |
|
||||||
|
| Search finds only exact words | The embedding model hasn't downloaded yet | Give the app internet access once, or wait for the first download to finish |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Optional but recommended — set a secret key
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
export SECRET_KEY=your-random-secret-here
|
|
||||||
|
|
||||||
docker compose -f docker-compose.quickstart.yml up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5000`. The first user to register becomes admin. To connect Claude, create an API key under **Settings → MCP Access** and install the Claude Code plugin — see [API Keys & MCP](docs/api-keys-and-mcp.md).
|
[Development](docs/development.md) covers checks, migrations, the plugin and CI. [Architecture](docs/architecture.md) describes how it is built, and the [API reference](docs/api-reference.md) lists every REST endpoint.
|
||||||
|
|
||||||
> **Development:** To build from source, see [Development](docs/development.md).
|
## Licence
|
||||||
|
|
||||||
## Documentation
|
Fabled Grimoire is free software under the [GNU Affero General Public License v3.0 only](LICENSE) (`AGPL-3.0-only`). If you run a modified version for other people over a network, the licence requires you to offer them its source.
|
||||||
|
|
||||||
| Doc | Contents |
|
|
||||||
|-----|----------|
|
|
||||||
| [Architecture](docs/architecture.md) | Stack, design decisions, data models, key services |
|
|
||||||
| [Configuration](docs/configuration.md) | Environment variables, Docker Compose, production setup, security |
|
|
||||||
| [Features](docs/features.md) | Detailed feature breakdown and keyboard shortcuts |
|
|
||||||
| [Development](docs/development.md) | Dev workflow, CI/CD, migrations, release process |
|
|
||||||
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API keys and installing the Claude Code plugin |
|
|
||||||
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
|
|
||||||
| [Backups](docs/backups.md) | Scheduled backups to a folder or S3, and restoring from one |
|
|
||||||
| [Design system](docs/design-system.md) | The FabledSword house style and how Grimoire applies it |
|
|
||||||
| [API Reference](docs/api-reference.md) | All REST API endpoints |
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is privately maintained.
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""drop machine-written uses edges that cross projects
|
||||||
|
|
||||||
|
Revision ID: 0125
|
||||||
|
Revises: 0124
|
||||||
|
Create Date: 2026-10-26
|
||||||
|
|
||||||
|
A `uses` edge with basis `reference` is the proposer's by-name reading: the
|
||||||
|
shape's body names a canon's symbol. A body never calls a function defined in
|
||||||
|
another project's repository, so such an edge across projects is a common
|
||||||
|
word matched, not a call. The proposer no longer writes them, and this removes
|
||||||
|
the ones it wrote before. Edges a person or an audit judged keep their own
|
||||||
|
basis and are left alone. Rows within one project are re-read on the next
|
||||||
|
coverage refresh, which replaces their reference edges under the same rule.
|
||||||
|
|
||||||
|
Downgrade restores nothing: the removed edges were never facts.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0125"
|
||||||
|
down_revision = "0124"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"DELETE FROM code_shape_uses u USING code_shapes s, notes n "
|
||||||
|
"WHERE u.shape_id = s.id AND u.snippet_id = n.id "
|
||||||
|
"AND u.basis = 'reference' "
|
||||||
|
"AND n.project_id IS NOT NULL AND n.project_id <> s.project_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""drop write-path uses edges that cross projects
|
||||||
|
|
||||||
|
Revision ID: 0126
|
||||||
|
Revises: 0125
|
||||||
|
Create Date: 2026-10-26
|
||||||
|
|
||||||
|
The write path records a `uses` edge with basis `hook` when a session pulled a
|
||||||
|
snippet and then wrote code naming its symbol. That is by-name evidence, like
|
||||||
|
the proposer's `reference` edges 0125 removed, and a body never calls a
|
||||||
|
function defined in another project's repository. The write path now applies
|
||||||
|
the same by-name test, so it writes no such edge; this removes the ones it
|
||||||
|
wrote before. Judged edges keep their own basis and are left alone.
|
||||||
|
|
||||||
|
Downgrade restores nothing: the removed edges were never facts.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0126"
|
||||||
|
down_revision = "0125"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"DELETE FROM code_shape_uses u USING code_shapes s, notes n "
|
||||||
|
"WHERE u.shape_id = s.id AND u.snippet_id = n.id "
|
||||||
|
"AND u.basis = 'hook' "
|
||||||
|
"AND n.project_id IS NOT NULL AND n.project_id <> s.project_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""instance identity — the id lineage a backup carries
|
||||||
|
|
||||||
|
Revision ID: 0127
|
||||||
|
Revises: 0126
|
||||||
|
Create Date: 2026-11-05
|
||||||
|
|
||||||
|
One row naming this database's id lineage. Every full backup carries it, a
|
||||||
|
restore into a fresh instance adopts it (the restore keeps the original ids),
|
||||||
|
and rolling a live instance back requires the backup's to match, so a backup's
|
||||||
|
ids never land on unrelated rows. Made here with a random value, so every
|
||||||
|
instance has one before its first account exists.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0127"
|
||||||
|
down_revision = "0126"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"instance_identity",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("instance_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.CheckConstraint("id = 1", name="ck_instance_identity_single_row"),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"INSERT INTO instance_identity (id, instance_id) VALUES (1, gen_random_uuid()::text)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("instance_identity")
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# CI Requirements — FabledScribe
|
# CI Requirements — FabledGrimoire
|
||||||
|
|
||||||
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
|
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
|
||||||
> in the CI-Runner repo.
|
> in the CI-Runner repo.
|
||||||
|
|||||||
@@ -1,36 +1,35 @@
|
|||||||
# Fabled Grimoire from the pre-built image:
|
# Grimoire built from this checkout, for development:
|
||||||
# docker compose -f docker-compose.quickstart.yml up -d
|
# docker compose -f docker-compose.dev.yml up --build
|
||||||
# Then open http://localhost:5000 (the first account registered becomes admin)
|
|
||||||
# and connect Claude from Settings → MCP Access.
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: git.fabledsword.com/bvandeusen/fabledgrimoire:latest
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
environment:
|
|
||||||
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${POSTGRES_PASSWORD:-grimoire}@db:5432/${POSTGRES_DB:-grimoire}"
|
|
||||||
# Set SECRET_KEY in the environment or a .env file beside this one.
|
|
||||||
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
|
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
|
||||||
volumes:
|
|
||||||
- app_data:/data
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- app_data:/data
|
||||||
|
# Or a bind mount, for direct host access to the app's data:
|
||||||
|
# - ./data:/data
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${POSTGRES_PASSWORD:-grimoire}@db:5432/${POSTGRES_DB:-grimoire}"
|
||||||
|
# Optional: a SearXNG instance to list as the web-search backend in Integrations.
|
||||||
|
# SEARXNG_URL: "http://searxng:8080"
|
||||||
|
|
||||||
db:
|
db:
|
||||||
# Postgres 17 with the vector extension Grimoire needs.
|
# Postgres 17 with the vector extension Grimoire needs.
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
stop_grace_period: 120s
|
stop_grace_period: 120s
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-grimoire}
|
POSTGRES_USER: ${POSTGRES_USER:-grimoire}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-grimoire}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-grimoire}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-grimoire}
|
POSTGRES_DB: ${POSTGRES_DB:-grimoire}
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
app_data:
|
|
||||||
pgdata:
|
pgdata:
|
||||||
|
app_data:
|
||||||
@@ -3,10 +3,16 @@ services:
|
|||||||
image: git.fabledsword.com/bvandeusen/fabledgrimoire:latest
|
image: git.fabledsword.com/bvandeusen/fabledgrimoire:latest
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${DB_PASSWORD}@db:5432/${POSTGRES_DB:-grimoire}"
|
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${DB_PASSWORD}@db:5432/${POSTGRES_DB:-grimoire}"
|
||||||
|
# A Swarm can start the app on another node, away from the key kept in
|
||||||
|
# a node-local /data volume, so a stack sets its key itself (or mounts
|
||||||
|
# a Docker secret and sets SECRET_KEY_FILE to its path).
|
||||||
SECRET_KEY: "${SECRET_KEY}"
|
SECRET_KEY: "${SECRET_KEY}"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||||
TRUST_PROXY_HEADERS: "true"
|
# Proxies in front of the app (Traefik = 1). The client address and
|
||||||
SECURE_COOKIES: "true"
|
# HTTPS are read from that far in from the right of X-Forwarded-*.
|
||||||
|
TRUSTED_PROXY_HOPS: "1"
|
||||||
|
volumes:
|
||||||
|
- app_data:/data
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
@@ -26,6 +32,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
name: ${PGDATA_VOLUME:-grimoire_pgdata}
|
name: ${PGDATA_VOLUME:-grimoire_pgdata}
|
||||||
|
app_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
+19
-12
@@ -1,33 +1,40 @@
|
|||||||
|
# Fabled Grimoire: the app and its database.
|
||||||
|
# docker compose up -d
|
||||||
|
# Then open http://localhost:5000. The first account registered becomes admin.
|
||||||
|
#
|
||||||
|
# Keep both volumes. app_data holds the instance's own SECRET_KEY, which seals
|
||||||
|
# saved credentials; pgdata holds everything else.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
image: git.fabledsword.com/bvandeusen/fabledgrimoire:latest
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${POSTGRES_PASSWORD:-grimoire}@db:5432/${POSTGRES_DB:-grimoire}"
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||||
|
# Port 5000 is published straight to clients, so no proxy is trusted.
|
||||||
|
# Behind a reverse proxy, set 1 (one per proxy) and BASE_URL:
|
||||||
|
TRUSTED_PROXY_HOPS: "0"
|
||||||
|
# BASE_URL: "https://grimoire.example.com"
|
||||||
|
volumes:
|
||||||
|
- app_data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
|
||||||
- app_data:/data
|
|
||||||
# Or a bind mount, for direct host access to the app's data:
|
|
||||||
# - ./data:/data
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${POSTGRES_PASSWORD:-grimoire}@db:5432/${POSTGRES_DB:-grimoire}"
|
|
||||||
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
|
||||||
# Optional: a SearXNG instance to list as the web-search backend in Integrations.
|
|
||||||
# SEARXNG_URL: "http://searxng:8080"
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
# Postgres 17 with the vector extension Grimoire needs.
|
# Postgres 17 with the vector extension Grimoire needs.
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
stop_grace_period: 120s
|
stop_grace_period: 120s
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-grimoire}
|
POSTGRES_USER: ${POSTGRES_USER:-grimoire}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-grimoire}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-grimoire}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-grimoire}
|
POSTGRES_DB: ${POSTGRES_DB:-grimoire}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
|
||||||
app_data:
|
app_data:
|
||||||
|
pgdata:
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ without a read-shaped name are spelled out here. A `write`-scoped key may call e
|
|||||||
The plugin connects the MCP server and adds the hooks and skills that put Grimoire's records in front of Claude as it works. Install it from this repo:
|
The plugin connects the MCP server and adds the hooks and skills that put Grimoire's records in front of Claude as it works. Install it from this repo:
|
||||||
|
|
||||||
```
|
```
|
||||||
/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledScribe.git
|
/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledGrimoire.git
|
||||||
/plugin install grimoire@grimoire-plugin
|
/plugin install grimoire@grimoire-plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ These serve the plugin's hooks.
|
|||||||
| POST | `/api/plugin/rule-opened` | Records that a session opened a rule it was shown |
|
| POST | `/api/plugin/rule-opened` | Records that a session opened a rule it was shown |
|
||||||
| GET | `/api/plugin/shape-check` | The end-of-turn question about what this turn wrote |
|
| GET | `/api/plugin/shape-check` | The end-of-turn question about what this turn wrote |
|
||||||
| GET | `/api/plugin/claim-session` · `/release-session` | Bind a session to its task claims / release them at session end |
|
| GET | `/api/plugin/claim-session` · `/release-session` | Bind a session to its task claims / release them at session end |
|
||||||
|
| GET | `/api/plugin/write-scope` | Which projects a pending act would change (a directory, or an MCP call's `tool` and `input`), and whether a session in its own project may do it. Filing an issue elsewhere is allowed; every other cross-project write is answered `allowed: false` with the reason the operator is asked |
|
||||||
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
|
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
|
||||||
| GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` |
|
| GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` |
|
||||||
| GET / POST | `/api/projects/<id>/coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups` — css groups carry `consumers`, `derive_new`, `unused_css`, `divergence`, `recheck`) |
|
| GET / POST | `/api/projects/<id>/coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups` — css groups carry `consumers`, `derive_new`, `unused_css`, `divergence`, `recheck`) |
|
||||||
@@ -272,12 +273,15 @@ These serve the plugin's hooks.
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/admin/backup` | Export backup (format v4; `?scope=user` for own data) |
|
| GET | `/api/admin/backup` | Export backup (format v4; `?scope=user` for own data) |
|
||||||
| POST | `/api/admin/restore` | Restore from a backup |
|
| GET | `/api/admin/restore/sources` | The backups at the backup destination, newest first |
|
||||||
|
| POST | `/api/admin/restore/preview` | What restoring a backup (`?source=<name>` or an uploaded file) would change; 409 with the reason when it can't apply here |
|
||||||
|
| POST | `/api/admin/restore?confirm=rollback` | Put the whole instance back to that backup, after saving the current state |
|
||||||
| GET / DELETE | `/api/admin/users` · `/api/admin/users/:id` | List / delete users |
|
| GET / DELETE | `/api/admin/users` · `/api/admin/users/:id` | List / delete users |
|
||||||
| GET / PUT | `/api/admin/registration` | Get / toggle registration |
|
| GET / PUT | `/api/admin/registration` | Get / toggle registration |
|
||||||
| GET / PUT | `/api/admin/smtp` · POST `/api/admin/smtp/test` | SMTP config + test email |
|
| GET / PUT | `/api/admin/smtp` · POST `/api/admin/smtp/test` | SMTP config + test email |
|
||||||
| GET | `/api/admin/logs` · `/api/admin/logs/stats` | Log entries + category counts |
|
| GET | `/api/admin/logs` · `/api/admin/logs/stats` | Log entries + category counts |
|
||||||
| GET / PUT | `/api/admin/base-url` | Get / set the public base URL |
|
| GET / PUT | `/api/admin/base-url` | Get / set the public base URL |
|
||||||
|
| GET / PUT | `/api/admin/proxy` | Get / set how many reverse proxies stand in front of Grimoire (`{"hops": n}`, or `null` for the environment's count), with what each count would read for this request |
|
||||||
| GET / PUT | `/api/admin/db-maintenance` (+ `/health`, POST `/run`) | VACUUM schedule, health, manual run |
|
| GET / PUT | `/api/admin/db-maintenance` (+ `/health`, POST `/run`) | VACUUM schedule, health, manual run |
|
||||||
| POST / GET / DELETE | `/api/admin/invitations` (+ `/:id`) | Create / list / revoke invite links |
|
| POST / GET / DELETE | `/api/admin/invitations` (+ `/:id`) | Create / list / revoke invite links |
|
||||||
| GET / PUT | `/api/admin/backups` | Scheduled backups: destination, schedule and recent runs |
|
| GET / PUT | `/api/admin/backups` | Scheduled backups: destination, schedule and recent runs |
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ Every database wait on startup has a deadline. Migrations wait for the database
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
- **Sessions**: `HttpOnly`, `SameSite=Lax` cookies, `Secure` when `SECURE_COOKIES=true`. Changing a password bumps the user's session version and signs out their other sessions.
|
- **Sessions**: `HttpOnly`, `SameSite=Lax` cookies, `Secure` on a request the trusted proxy reports as HTTPS, which also gets HSTS (`grimoire/proxy.py`). Changing a password bumps the user's session version and signs out their other sessions.
|
||||||
- **API keys**: `Authorization: Bearer fmcp_...`, stored hashed. A `read` key may only call read endpoints and read MCP tools.
|
- **API keys**: `Authorization: Bearer fmcp_...`, stored hashed. A `read` key may only call read endpoints and read MCP tools.
|
||||||
- **OIDC** single sign-on with PKCE, optionally replacing local logins ([SSO / OAuth](sso-oauth.md)).
|
- **OIDC** single sign-on with PKCE, optionally replacing local logins ([SSO / OAuth](sso-oauth.md)).
|
||||||
- **Rate limits** on register, login and forgot-password, per client IP (`TRUST_PROXY_HEADERS` reads the IP from the proxy's headers).
|
- **Rate limits** on register, login and forgot-password, per client IP, read from `X-Forwarded-For` by `TRUSTED_PROXY_HOPS`.
|
||||||
|
|
||||||
Credentials Grimoire stores for outside services (forge tokens, the backup destination's S3 secret) are sealed under a key derived from `SECRET_KEY`.
|
Credentials Grimoire stores for outside services (forge tokens, the backup destination's S3 secret) are sealed under a key derived from `SECRET_KEY`, which the instance makes on first start and keeps in `/data/secret_key` unless one is set.
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ Credentials Grimoire stores for outside services (forge tokens, the backup desti
|
|||||||
│ ├── routes/ REST blueprints
|
│ ├── routes/ REST blueprints
|
||||||
│ ├── mcp/ MCP server and its tool modules
|
│ ├── mcp/ MCP server and its tool modules
|
||||||
│ ├── services/ shared logic
|
│ ├── services/ shared logic
|
||||||
│ └── restore.py `python -m grimoire.restore` for a fresh instance
|
│ └── restore.py `python -m grimoire.restore`: the restore, from the command line
|
||||||
├── frontend/src/ views, components, stores, composables, api client
|
├── frontend/src/ views, components, stores, composables, api client
|
||||||
├── plugin/ Claude Code hooks, skills and commands
|
├── plugin/ Claude Code hooks, skills and commands
|
||||||
├── scripts/ CI checks and the plugin version minter
|
├── scripts/ CI checks and the plugin version minter
|
||||||
|
|||||||
+54
-22
@@ -31,7 +31,8 @@ differently. It does **not** hold:
|
|||||||
- **Credentials:** API keys and forge connections. After a restore, issue new
|
- **Credentials:** API keys and forge connections. After a restore, issue new
|
||||||
API keys and add forge connections again. (Forge tokens are also sealed under
|
API keys and add forge connections again. (Forge tokens are also sealed under
|
||||||
`SECRET_KEY` in the database itself, so a copy of the database doesn't carry
|
`SECRET_KEY` in the database itself, so a copy of the database doesn't carry
|
||||||
them in readable form either.)
|
them in readable form either. The key lives in `/data/secret_key`, outside
|
||||||
|
the database.)
|
||||||
- **Logs and notifications.**
|
- **Logs and notifications.**
|
||||||
- **The backup destination's own S3 secret.**
|
- **The backup destination's own S3 secret.**
|
||||||
|
|
||||||
@@ -87,9 +88,12 @@ You need four things:
|
|||||||
- **Access key.**
|
- **Access key.**
|
||||||
- **Secret key.**
|
- **Secret key.**
|
||||||
|
|
||||||
Grimoire uploads and lists; it never deletes. A key that may **only add
|
Grimoire uploads and lists, and reads a backup back when you restore from the
|
||||||
objects** is therefore enough, and it is the safer key to hand out: a stolen
|
bucket; it never deletes. A key that may **add and read objects but not
|
||||||
one cannot wipe the history.
|
delete them** is therefore enough, and it is the safer key to hand out: a
|
||||||
|
stolen one cannot wipe the history. A key that may only add objects still
|
||||||
|
takes backups; restore then works by uploading a file or from the command
|
||||||
|
line.
|
||||||
|
|
||||||
The secret is stored encrypted, under a key derived from `SECRET_KEY`. It is
|
The secret is stored encrypted, under a key derived from `SECRET_KEY`. It is
|
||||||
never shown again, and never included in a backup. If an instance's
|
never shown again, and never included in a backup. If an instance's
|
||||||
@@ -121,8 +125,10 @@ keeps service backups as "backup targets". Either destination works with it.
|
|||||||
1. On Archives' **Backups** page, make a target for Grimoire, named `grimoire`
|
1. On Archives' **Backups** page, make a target for Grimoire, named `grimoire`
|
||||||
for example.
|
for example.
|
||||||
2. Choose how Grimoire's files arrive:
|
2. Choose how Grimoire's files arrive:
|
||||||
- **S3**: turn on the S3 way in for the target and make an **append-only**
|
- **S3**: turn on the S3 way in for the target and make an S3 key that
|
||||||
S3 key. In Grimoire, choose **An S3 bucket**:
|
may add and read objects but not delete them (an **append-only** key
|
||||||
|
takes backups too, but then Grimoire can't restore from the bucket). In
|
||||||
|
Grimoire, choose **An S3 bucket**:
|
||||||
- address: Archives' backup address (`http://<archives>:8081`);
|
- address: Archives' backup address (`http://<archives>:8081`);
|
||||||
- bucket: the target's name;
|
- bucket: the target's name;
|
||||||
- access key and secret key: the key you just made.
|
- access key and secret key: the key you just made.
|
||||||
@@ -138,26 +144,52 @@ keeps service backups as "backup targets". Either destination works with it.
|
|||||||
|
|
||||||
## Restoring
|
## Restoring
|
||||||
|
|
||||||
### Into a fresh instance
|
A restore puts the **whole instance** back to the moment a backup was taken,
|
||||||
|
for every user:
|
||||||
|
|
||||||
Start Grimoire on an empty database, copy the backup file into the container,
|
- records edited since go back to how they were;
|
||||||
and run:
|
- records deleted since come back;
|
||||||
|
- anything made since is removed.
|
||||||
|
|
||||||
|
It matches records by their ids, so a live instance only restores a backup **of
|
||||||
|
its own**. Each instance has an id, and every backup records the one it came
|
||||||
|
from. A backup from another instance, or one made before backups recorded it,
|
||||||
|
only goes into a new, empty instance, which takes it as it is.
|
||||||
|
|
||||||
|
Before changing anything, the restore saves the current state as one more
|
||||||
|
backup. It goes to the backup destination when scheduled backups are on, and
|
||||||
|
otherwise to `/data/pre-restore`. The result says which file it is, so a
|
||||||
|
restore can itself be undone.
|
||||||
|
|
||||||
|
What a backup doesn't carry stays as it is wherever its owner survives the
|
||||||
|
restore: API keys, forge connections, sharing, logs, and the destination's S3
|
||||||
|
secret. The trash isn't in backups, so anything in the trash when the backup
|
||||||
|
was taken is gone afterwards. Everyone is signed out, and search is rebuilt.
|
||||||
|
|
||||||
|
### From the web
|
||||||
|
|
||||||
|
**Settings → Data → Restore from Backup** lists the backups at the backup
|
||||||
|
destination. You can pick one of those, or upload a `.tar` or the JSON
|
||||||
|
**Full Backup** download (uploads are held to the server's request-size
|
||||||
|
limit). The restore then shows what would change: when the backup was taken,
|
||||||
|
and how many accounts, projects, plans, notes and rules there are now and will
|
||||||
|
be afterwards. Type `roll back` to run it. Then sign in again.
|
||||||
|
|
||||||
|
To read backups back from an S3 bucket, the key needs read access as well as
|
||||||
|
write. Grimoire never deletes from the bucket.
|
||||||
|
|
||||||
|
### From the command line
|
||||||
|
|
||||||
|
For a file with no size limit, or a new instance with no backup destination
|
||||||
|
yet, copy the file into the container and run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker exec -it grimoire python -m grimoire.restore /backups/grimoire-20261015T020000Z.tar
|
docker exec -it grimoire python -m grimoire.restore /backups/grimoire-20261015T020000Z.tar
|
||||||
```
|
```
|
||||||
|
|
||||||
- The command refuses a database that already has an account. A restore
|
It prints what would change and stops. Add `--yes` to restore. It reads the
|
||||||
re-creates everything with new ids, so on a live instance it would add a
|
JSON download too. Afterwards, **restart Grimoire** so the search indexes are
|
||||||
second copy instead of replacing anything.
|
rebuilt.
|
||||||
- Nothing limits the file's size.
|
|
||||||
- It also reads the JSON from the admin **Full Backup** download.
|
|
||||||
|
|
||||||
Afterwards, **restart Grimoire** so the search indexes are rebuilt. Then issue
|
After restoring into a new instance, issue new API keys, add forge connections
|
||||||
new API keys, add forge connections again, and set up any sharing.
|
again, and set up any sharing: none of them travel in a backup.
|
||||||
|
|
||||||
### From the web
|
|
||||||
|
|
||||||
**Settings → Data → Restore from Backup** takes the `.tar` as well as the
|
|
||||||
JSON download. The upload is held to the server's request-size limit. For a
|
|
||||||
bigger file, use the command above.
|
|
||||||
|
|||||||
+24
-17
@@ -11,10 +11,10 @@ Any variable marked *secret* can also be read from a file: set `<NAME>_FILE` to
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `DATABASE_URL` *(secret)* | `postgresql+asyncpg://grimoire:grimoire@localhost:5432/grimoire` | PostgreSQL connection string, using the `asyncpg` driver |
|
| `DATABASE_URL` *(secret)* | `postgresql+asyncpg://grimoire:grimoire@localhost:5432/grimoire` | PostgreSQL connection string, using the `asyncpg` driver |
|
||||||
| `SECRET_KEY` *(secret)* | `dev-secret-change-me` | Signs sessions and seals stored credentials. Set it before first use and keep it (see below) |
|
| `SECRET_KEY` *(secret)* | kept in `DATA_DIR` | Signs sessions and seals stored credentials. Unset, the instance makes its own on first start and keeps it in `DATA_DIR/secret_key`. Set it only to manage the key yourself; it wins over the kept one (see below) |
|
||||||
|
| `DATA_DIR` | `/data` | Where the instance keeps its own `SECRET_KEY`. Mount a volume here |
|
||||||
| `BASE_URL` | `http://localhost:5000` | Public URL, used in email links and OIDC redirects |
|
| `BASE_URL` | `http://localhost:5000` | Public URL, used in email links and OIDC redirects |
|
||||||
| `SECURE_COOKIES` | `false` | `true` when Grimoire is served over HTTPS |
|
| `TRUSTED_PROXY_HOPS` | `1` | The starting count of reverse proxies in front of Grimoire. **Settings → Config → Reverse proxy** is where the count is checked and set, and a count saved there wins over this. The client address and the scheme are read that many entries in from the right of `X-Forwarded-For` and `X-Forwarded-Proto`; `0` ignores both headers. See [Behind a reverse proxy](#behind-a-reverse-proxy) |
|
||||||
| `TRUST_PROXY_HEADERS` | `false` | `true` behind a reverse proxy, to read the client IP from `X-Forwarded-For` / `X-Real-IP` |
|
|
||||||
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING` or `ERROR` |
|
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING` or `ERROR` |
|
||||||
| `LOG_RETENTION_DAYS` | `90` | Days of app logs to keep |
|
| `LOG_RETENTION_DAYS` | `90` | Days of app logs to keep |
|
||||||
| `FASTEMBED_CACHE_DIR` | `/data/fastembed-cache` | Where the embedding model is stored after its first download |
|
| `FASTEMBED_CACHE_DIR` | `/data/fastembed-cache` | Where the embedding model is stored after its first download |
|
||||||
@@ -56,27 +56,27 @@ Admins can set SMTP in **Settings**; these are the defaults until they do.
|
|||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
### Quick start
|
### Docker Compose
|
||||||
|
|
||||||
[`docker-compose.quickstart.yml`](../docker-compose.quickstart.yml) runs the published image with its own database:
|
[`docker-compose.yml`](../docker-compose.yml) runs the published image with its own database, and needs nothing set:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(32))') \
|
docker compose up -d
|
||||||
docker compose -f docker-compose.quickstart.yml up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5000`. The first account registered becomes the admin; after that, registration stays closed until an admin opens it in Settings or sends an invite.
|
Open `http://localhost:5000`. The first account registered becomes the admin; after that, registration stays closed until an admin opens it in Settings or sends an invite.
|
||||||
|
|
||||||
|
Update with `docker compose pull && docker compose up -d`. The key and the data stay in their volumes.
|
||||||
|
|
||||||
### From source
|
### From source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # then set SECRET_KEY
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
docker compose up --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Swarm
|
### Docker Swarm
|
||||||
|
|
||||||
[`docker-compose.prod.yml`](../docker-compose.prod.yml) is a stack file: the app and database on a private overlay network, with `SECURE_COOKIES` and `TRUST_PROXY_HEADERS` on. Provide `DB_PASSWORD` and `SECRET_KEY` in the environment:
|
[`docker-compose.prod.yml`](../docker-compose.prod.yml) is a stack file: the app and database on a private overlay network, trusting one proxy (`TRUSTED_PROXY_HOPS: "1"`). A Swarm can move the app to another node, away from a node-local `/data` volume, so the stack sets its own key: provide `DB_PASSWORD` and `SECRET_KEY` in the environment (or mount a Docker secret and set `SECRET_KEY_FILE`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DB_PASSWORD=... SECRET_KEY=... docker stack deploy -c docker-compose.prod.yml grimoire
|
DB_PASSWORD=... SECRET_KEY=... docker stack deploy -c docker-compose.prod.yml grimoire
|
||||||
@@ -89,19 +89,22 @@ The shipped files leave out healthchecks and restart policies on purpose. The ap
|
|||||||
### Volumes
|
### Volumes
|
||||||
|
|
||||||
- **Database** (`pgdata`): everything Grimoire stores.
|
- **Database** (`pgdata`): everything Grimoire stores.
|
||||||
- **App data** (`/data`): the embedding model cache and diagnostics. Losing it costs a re-download, nothing else.
|
- **App data** (`/data`): the instance's own `SECRET_KEY`, the embedding model cache and diagnostics. Losing it costs the key: Settings then asks for saved forge tokens and the backup secret again, and the model downloads again.
|
||||||
|
|
||||||
Back up the database with **Settings → Data → Scheduled backups** ([Backups](backups.md)).
|
Back up the database with **Settings → Data → Scheduled backups** ([Backups](backups.md)). A database backup doesn't carry the key; to move sealed credentials to a new instance, copy `/data/secret_key` with it.
|
||||||
|
|
||||||
## Behind a reverse proxy
|
## Behind a reverse proxy
|
||||||
|
|
||||||
Grimoire doesn't terminate TLS. Put it behind Nginx, Traefik or Caddy, forward to port 5000, and keep port 5000 off the internet. Set `SECURE_COOKIES=true`, `TRUST_PROXY_HEADERS=true` and `BASE_URL`.
|
Grimoire doesn't terminate TLS. Put it behind Nginx, Traefik or Caddy, forward to port 5000, and keep port 5000 off the internet. Set `BASE_URL`, then set the proxy count to the number of proxies a request passes through: one proxy is `1`, the default; Cloudflare's proxied DNS in front of your own proxy is `2`.
|
||||||
|
|
||||||
|
**Settings → Config → Reverse proxy** sets the count and checks it against your own request. It shows the address Grimoire reads you as, and what each count from 0 to 3 would read. Pick the count that shows your own address. When a request comes through Cloudflare, the section names the count that reads the address Cloudflare reports, with a button that sets it. `TRUSTED_PROXY_HOPS` in the environment is only the starting value: a count saved in Settings wins, and **Use the environment's** goes back to it.
|
||||||
|
|
||||||
|
A count that is too low makes every visitor read as the proxy, so they share one sign-in rate limit and a few bad sign-ins block everyone. The "too many attempts" message says so and points here. The block on sign-ins lifts after a minute; an admin who is already signed in can fix the count at once.
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:5000;
|
proxy_pass http://127.0.0.1:5000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
@@ -111,12 +114,16 @@ location / {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
With `TRUST_PROXY_HEADERS=true`, make sure the proxy replaces any `X-Forwarded-For` the client sends.
|
Each proxy appends its entry to `X-Forwarded-For` and `X-Forwarded-Proto`, so Grimoire reads the entry that many in from the right: the one your own proxy wrote. Whatever a client puts to the left of it is ignored. A header with fewer entries than that (a LAN road with one proxy beside a Cloudflare road with two) is read at its leftmost entry, the one the first proxy wrote.
|
||||||
|
|
||||||
|
When that entry says `https`, the session cookie is marked `Secure` and the response carries `Strict-Transport-Security: max-age=31536000`. Over plain HTTP neither is sent, and Grimoire never redirects to HTTPS, so a LAN install keeps working.
|
||||||
|
|
||||||
|
A hop count higher than the real number of proxies lets a client choose its own address, so count them. With `0`, the headers are ignored and the socket peer is the client: use it when clients reach port 5000 directly.
|
||||||
|
|
||||||
## Security checklist
|
## Security checklist
|
||||||
|
|
||||||
- **Set a strong `SECRET_KEY`, and keep it.** Forge tokens and the backup destination's S3 secret are sealed under a key derived from it, so a copy of the database alone can't read them. If the key changes (say, a restore onto an instance with a different key), Settings marks those credentials and asks for them again; nothing else breaks. The app logs a warning at startup while the default key is in use, and refuses to start with it when `SECURE_COOKIES=true`.
|
- **Keep the key.** Forge tokens and the backup destination's S3 secret are sealed under a key derived from `SECRET_KEY`, so a copy of the database alone can't read them. Unset, the instance makes a random key and keeps it in `/data/secret_key`, readable by its owner only; keep that volume. If the key changes (say, a restore onto an instance with a different key), Settings marks those credentials and asks for them again; nothing else breaks. The app warns at startup when `SECRET_KEY` is a public placeholder or `/data` is not a mounted volume, and refuses to start with a placeholder key (`dev-secret-change-me`, `change-me-in-production`).
|
||||||
- **`SECURE_COOKIES=true`** whenever Grimoire is served over HTTPS.
|
- **The proxy count** (Settings → Config → Reverse proxy) matches the number of proxies in front of Grimoire, so rate limits and the audit log see the real client and the cookie is `Secure` over HTTPS. The section shows whether it does.
|
||||||
- **Registration** is open only for the first account. Invite people, or open it briefly from Settings.
|
- **Registration** is open only for the first account. Invite people, or open it briefly from Settings.
|
||||||
- **Signing everyone else out**: changing a password ends that user's other sessions; Settings → Account has a button for it too.
|
- **Signing everyone else out**: changing a password ends that user's other sessions; Settings → Account has a button for it too.
|
||||||
- **API keys** are per user, shown once, and can be read-only. Revoke any you no longer use under Settings → MCP Access.
|
- **API keys** are per user, shown once, and can be read-only. Revoke any you no longer use under Settings → MCP Access.
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ All text-on-surface combinations meet WCAG AA at minimum. Parchment on Obsidian
|
|||||||
| Inter | Body, UI, labels | Google Fonts |
|
| Inter | Body, UI, labels | Google Fonts |
|
||||||
| JetBrains Mono | Code, terminal output, monospaced data | Google Fonts |
|
| JetBrains Mono | Code, terminal output, monospaced data | Google Fonts |
|
||||||
|
|
||||||
|
All three are served by the app itself, from `frontend/src/assets/fonts/`, with every subset Google Fonts publishes for them. A page never asks a font host for anything, so an instance with no internet still draws in its own type. They are under the SIL Open Font License; the licences sit beside the files.
|
||||||
|
|
||||||
### Why this pairing
|
### Why this pairing
|
||||||
|
|
||||||
Fraunces is a contemporary serif with personality — it has the warmth and authority of a book serif without feeling like costume. It signals "this is considered" without signaling "this is a fantasy product." Inter is the workhorse — neutral, ubiquitous, designed for screens, doesn't compete with the serif. JetBrains Mono is the natural choice for any developer-adjacent product and supports ligatures.
|
Fraunces is a contemporary serif with personality — it has the warmth and authority of a book serif without feeling like costume. It signals "this is considered" without signaling "this is a fantasy product." Inter is the workhorse — neutral, ubiquitous, designed for screens, doesn't compete with the serif. JetBrains Mono is the natural choice for any developer-adjacent product and supports ligatures.
|
||||||
|
|||||||
+5
-5
@@ -3,9 +3,9 @@
|
|||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build # app + PostgreSQL at http://localhost:5000
|
docker compose -f docker-compose.dev.yml up --build # app + PostgreSQL at http://localhost:5000
|
||||||
docker compose up --build app # rebuild after a change
|
docker compose -f docker-compose.dev.yml up --build app # rebuild after a change
|
||||||
docker compose down -v # stop and wipe the database
|
docker compose -f docker-compose.dev.yml down -v # stop and wipe the database
|
||||||
```
|
```
|
||||||
|
|
||||||
The image builds the frontend, so a frontend change needs a rebuild too. For quicker frontend work, run Vite against the running app; it proxies `/api` to port 5000:
|
The image builds the frontend, so a frontend change needs a rebuild too. For quicker frontend work, run Vite against the running app; it proxies `/api` to port 5000:
|
||||||
@@ -32,7 +32,7 @@ Unit tests run without a database. Tests marked `integration` need a real Postgr
|
|||||||
The container runs `alembic upgrade head` on every start, after waiting for the database to accept connections.
|
The container runs `alembic upgrade head` on every start, after waiting for the database to accept connections.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec app alembic revision -m "short_description"
|
docker compose -f docker-compose.dev.yml exec app alembic revision -m "short_description"
|
||||||
```
|
```
|
||||||
|
|
||||||
- Number revisions sequentially (`0123_...`). Revision ids must fit in 32 characters, the width of `alembic_version.version_num`.
|
- Number revisions sequentially (`0123_...`). Revision ids must fit in 32 characters, the width of `alembic_version.version_num`.
|
||||||
@@ -57,7 +57,7 @@ CI runs on Gitea Actions (workflow in `.forgejo/workflows/ci.yml`) in the shared
|
|||||||
| `main` | `:latest`, `:<sha>` |
|
| `main` | `:latest`, `:<sha>` |
|
||||||
| a `v*` tag | `:latest`, `:<version>`, `:<sha>` |
|
| a `v*` tag | `:latest`, `:<version>`, `:<sha>` |
|
||||||
|
|
||||||
`:<sha>` never moves, so it is the rollback point. Images publish to `git.fabledsword.com/bvandeusen/fabledscribe`, with a registry layer cache at `:cache`. The build needs the `REGISTRY_USER` and `REGISTRY_TOKEN` (a token with `write:packages`) repository secrets.
|
`:<sha>` never moves, so it is the rollback point. Images publish to `git.fabledsword.com/bvandeusen/fabledgrimoire`, with a registry layer cache at `:cache`. The build needs the `REGISTRY_USER` and `REGISTRY_TOKEN` (a token with `write:packages`) repository secrets.
|
||||||
|
|
||||||
Work happens on `dev`. A release merges `dev` into `main` with a plain merge commit; a dated `vYYYY.MM.DD.HHMM` tag, matching the version the image reports, can mark it.
|
Work happens on `dev`. A release merges `dev` into `main` with a plain merge commit; a dated `vYYYY.MM.DD.HHMM` tag, matching the version the image reports, can mark it.
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -66,6 +66,7 @@ Saved procedures, such as a drift audit or a DRY pass. The plugin syncs each one
|
|||||||
The whole store is reachable through the MCP endpoint at `/mcp`, authenticated with an API key. The Claude Code plugin in this repo adds:
|
The whole store is reachable through the MCP endpoint at `/mcp`, authenticated with an API key. The Claude Code plugin in this repo adds:
|
||||||
|
|
||||||
- hooks for session start, each prompt, before and after tool calls, the end of a turn, compaction and session end, all failing open if Grimoire is unreachable;
|
- hooks for session start, each prompt, before and after tool calls, the end of a turn, compaction and session end, all failing open if Grimoire is unreachable;
|
||||||
|
- a project fence: a session belongs to the project its starting directory is bound to. Toward any other project it can read anything and file an issue. Editing that project's files, committing or pushing in its repo, or changing its records first asks you, in every permission mode;
|
||||||
- skills for the working practices: using Grimoire, writing plans, systematic debugging, verification, brainstorming, reporting back, reusing code, shape accounting and family canon;
|
- skills for the working practices: using Grimoire, writing plans, systematic debugging, verification, brainstorming, reporting back, reusing code, shape accounting and family canon;
|
||||||
- your stored processes as skills.
|
- your stored processes as skills.
|
||||||
|
|
||||||
@@ -85,8 +86,8 @@ Share a project, note or task with users or groups, as viewer, editor or admin.
|
|||||||
## Export and backup
|
## Export and backup
|
||||||
|
|
||||||
- **Personal export**: your notes and tasks as a Markdown ZIP (with YAML frontmatter) or JSON.
|
- **Personal export**: your notes and tasks as a Markdown ZIP (with YAML frontmatter) or JSON.
|
||||||
- **Admin backup and restore**, with ids remapped so a backup restores onto another instance.
|
- **Admin backup and restore**: a restore rolls the whole instance back to one of its own backups, after saving the current state, or brings a new instance back from any backup.
|
||||||
- **Scheduled backups**: a daily, dated, uncompressed tar to a folder or an S3-compatible bucket, laid out so a deduplicating tool such as restic stores only what changed. `python -m grimoire.restore` brings a fresh instance back from one. See [Backups](backups.md).
|
- **Scheduled backups**: a daily, dated, uncompressed tar to a folder or an S3-compatible bucket, laid out so a deduplicating tool such as restic stores only what changed. Restoring is in Settings, or `python -m grimoire.restore`. See [Backups](backups.md).
|
||||||
|
|
||||||
## Sign-in
|
## Sign-in
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ Email and password, plus optional OIDC single sign-on (Authentik, Keycloak, ...)
|
|||||||
| Integrations | SearXNG, git forge connections |
|
| Integrations | SearXNG, git forge connections |
|
||||||
| Data | Export, backup and restore, scheduled backups (admin) |
|
| Data | Export, backup and restore, scheduled backups (admin) |
|
||||||
| MCP Access | API keys and the command to connect Claude |
|
| MCP Access | API keys and the command to connect Claude |
|
||||||
| Config (admin) | What's running, application URL, plugin marketplace, database maintenance, SMTP, forge webhook |
|
| Config (admin) | What's running, reverse proxy count (checked against your own request), application URL, plugin marketplace, database maintenance, SMTP, forge webhook |
|
||||||
| Shared areas (admin) | The canonical areas systems map to |
|
| Shared areas (admin) | The canonical areas systems map to |
|
||||||
| Platforms (admin) | The platform catalogue family canon is grouped by |
|
| Platforms (admin) | The platform catalogue family canon is grouped by |
|
||||||
| Users (admin) | Registration, invites, users |
|
| Users (admin) | Registration, invites, users |
|
||||||
|
|||||||
+2
-1
@@ -4,8 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#6c63ff">
|
<meta name="theme-color" content="#14171A">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
|||||||
Generated
+1
@@ -7,6 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "grimoire-frontend",
|
"name": "grimoire-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fullcalendar/core": "^6.1.20",
|
"@fullcalendar/core": "^6.1.20",
|
||||||
"@fullcalendar/daygrid": "^6.1.20",
|
"@fullcalendar/daygrid": "^6.1.20",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "grimoire-frontend",
|
"name": "grimoire-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
+16
-31
@@ -1,33 +1,18 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
<style>
|
<!-- page block, seen at the fore-edge -->
|
||||||
.book { fill: #6366f1; stroke: #4f46e5; }
|
<path d="M8.5 4.5 H25.5 Q27 4.5 27 6 V27 Q27 28.5 25.5 28.5 H8.5 Z" fill="#E9DFC6"/>
|
||||||
.spine { stroke: #4f46e5; }
|
<path d="M25 7 V26.5" stroke="#C9BB97" stroke-width="0.6"/>
|
||||||
.lines { stroke: #a5b4fc; }
|
<!-- cover -->
|
||||||
@media (prefers-color-scheme: dark) {
|
<path d="M5 4 Q5 3 6 3 H24 Q25.5 3 25.5 4.5 V26.5 Q25.5 28 24 28 H6 Q5 28 5 27 Z" fill="#5B4A8A"/>
|
||||||
.book { fill: #e2e8f0; stroke: #cbd5e0; }
|
<!-- spine band -->
|
||||||
.spine { stroke: #cbd5e0; }
|
<rect x="5" y="3" width="3.2" height="25" rx="0.6" fill="#47396E"/>
|
||||||
.lines { stroke: #4a5568; }
|
<!-- corner fittings -->
|
||||||
}
|
<path d="M21.5 3 H24 Q25.5 3 25.5 4.5 V7 Z" fill="#D4A94E"/>
|
||||||
</style>
|
<path d="M21.5 28 H24 Q25.5 28 25.5 26.5 V24 Z" fill="#D4A94E"/>
|
||||||
<!-- Book body -->
|
<!-- clasp across the fore-edge -->
|
||||||
<path class="book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/>
|
<rect x="22.5" y="14" width="5.5" height="3.6" rx="0.8" fill="#D4A94E"/>
|
||||||
<!-- Book spine -->
|
<circle cx="26.4" cy="15.8" r="0.7" fill="#8A6A22"/>
|
||||||
<line class="spine" x1="16" y1="6" x2="16" y2="25" stroke-width="0.8"/>
|
<!-- sigil: a four-pointed star in a ring -->
|
||||||
<!-- Left page lines -->
|
<circle cx="15.5" cy="15.5" r="5.2" fill="none" stroke="#D4A94E" stroke-width="0.9"/>
|
||||||
<line class="lines" x1="7" y1="11" x2="14" y2="10.5" stroke-width="0.6" stroke-linecap="round"/>
|
<path d="M15.5 11.4 L16.5 14.5 L19.6 15.5 L16.5 16.5 L15.5 19.6 L14.5 16.5 L11.4 15.5 L14.5 14.5 Z" fill="#F1D48A"/>
|
||||||
<line class="lines" x1="7" y1="14" x2="14" y2="13.5" stroke-width="0.6" stroke-linecap="round"/>
|
|
||||||
<line class="lines" x1="7" y1="17" x2="14" y2="16.5" stroke-width="0.6" stroke-linecap="round"/>
|
|
||||||
<line class="lines" x1="7" y1="20" x2="12" y2="19.5" stroke-width="0.6" stroke-linecap="round"/>
|
|
||||||
<!-- Right page lines -->
|
|
||||||
<line class="lines" x1="18" y1="10.5" x2="25" y2="11" stroke-width="0.6" stroke-linecap="round"/>
|
|
||||||
<line class="lines" x1="18" y1="13.5" x2="25" y2="14" stroke-width="0.6" stroke-linecap="round"/>
|
|
||||||
<line class="lines" x1="18" y1="16.5" x2="25" y2="17" stroke-width="0.6" stroke-linecap="round"/>
|
|
||||||
<!-- Magic spark -->
|
|
||||||
<g transform="translate(24, 5)">
|
|
||||||
<path d="M0 -4 L1 -1 L4 0 L1 1 L0 4 L-1 1 L-4 0 L-1 -1 Z" fill="#f6ad55"/>
|
|
||||||
<path d="M0 -2.5 L0.6 -0.6 L2.5 0 L0.6 0.6 L0 2.5 L-0.6 0.6 L-2.5 0 L-0.6 -0.6 Z" fill="#fbd38d"/>
|
|
||||||
</g>
|
|
||||||
<!-- Secondary sparkles -->
|
|
||||||
<circle cx="20" cy="3" r="0.7" fill="#f6ad55" opacity="0.7"/>
|
|
||||||
<circle cx="27" cy="8" r="0.5" fill="#fbd38d" opacity="0.6"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,17 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "Fabled Grimoire",
|
"name": "Fabled Grimoire",
|
||||||
"short_name": "Grimoire",
|
"short_name": "Grimoire",
|
||||||
"description": "Your self-hosted second brain with AI assistance",
|
"description": "A self-hosted work system-of-record for software projects, built to be driven by Claude Code.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#1a1a2e",
|
"background_color": "#14171A",
|
||||||
"theme_color": "#6c63ff",
|
"theme_color": "#14171A",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/favicon.ico",
|
"src": "/favicon.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/x-icon"
|
"type": "image/svg+xml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiGet, apiPost, apiPut } from "@/api/client";
|
import { apiGet, apiPost, apiPostRaw, apiPut } from "@/api/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduled backups — `/api/admin/backups`. One dated,
|
* Scheduled backups — `/api/admin/backups`. One dated,
|
||||||
@@ -71,3 +71,72 @@ export function checkBackupDestination(
|
|||||||
export function runBackupNow(): Promise<{ status: string }> {
|
export function runBackupNow(): Promise<{ status: string }> {
|
||||||
return apiPost<{ status: string }>("/api/admin/backups/run", {});
|
return apiPost<{ status: string }>("/api/admin/backups/run", {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Restore: put the whole instance back to a full backup ─────────────────
|
||||||
|
|
||||||
|
export interface StoredBackup {
|
||||||
|
name: string;
|
||||||
|
size_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreCount {
|
||||||
|
kind: string;
|
||||||
|
now: number;
|
||||||
|
after: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestorePreview {
|
||||||
|
/** "fresh": an empty instance takes the backup. "rollback": this instance
|
||||||
|
* goes back to its own earlier backup. */
|
||||||
|
mode: "fresh" | "rollback";
|
||||||
|
exported_at: string | null;
|
||||||
|
counts: RestoreCount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreResult extends RestorePreview {
|
||||||
|
status: "ok";
|
||||||
|
restored: Record<string, number>;
|
||||||
|
safety_backup: { file: string; size_bytes: number; where: string; note?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A backup at the destination, by name, or a file picked from this computer. */
|
||||||
|
export type RestoreFrom = { source: string } | { file: File };
|
||||||
|
|
||||||
|
// Reading, checking and writing the whole store is slow by design; bounded,
|
||||||
|
// but in minutes.
|
||||||
|
const RESTORE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export function listRestoreSources(): Promise<{ backups: StoredBackup[]; error?: string }> {
|
||||||
|
return apiGet("/api/admin/restore/sources", { timeoutMs: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreCall<T>(path: string, from: RestoreFrom, query: Record<string, string> = {}): Promise<T> {
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
if ("source" in from) {
|
||||||
|
params.set("source", from.source);
|
||||||
|
return apiPostRaw<T>(`${path}?${params}`, null, null, { timeoutMs: RESTORE_TIMEOUT_MS });
|
||||||
|
}
|
||||||
|
const qs = params.toString() ? `?${params}` : "";
|
||||||
|
const isTar = from.file.name.toLowerCase().endsWith(".tar");
|
||||||
|
// The JSON download is parsed first, so a damaged file fails here, before
|
||||||
|
// the upload.
|
||||||
|
const body = isTar ? from.file : JSON.stringify(JSON.parse(await from.file.text()));
|
||||||
|
return apiPostRaw<T>(`${path}${qs}`, body, isTar ? "application/x-tar" : "application/json",
|
||||||
|
{ timeoutMs: RESTORE_TIMEOUT_MS });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewRestore(from: RestoreFrom): Promise<RestorePreview> {
|
||||||
|
return restoreCall<RestorePreview>("/api/admin/restore/preview", from);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreBackup(from: RestoreFrom): Promise<RestoreResult> {
|
||||||
|
return restoreCall<RestoreResult>("/api/admin/restore", from, { confirm: "rollback" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When a backup named `grimoire-20261015T020000Z.tar` was taken. */
|
||||||
|
export function backupTakenAt(name: string): Date | null {
|
||||||
|
const m = /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/.exec(name);
|
||||||
|
if (!m) return null;
|
||||||
|
return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]));
|
||||||
|
}
|
||||||
|
|||||||
@@ -156,6 +156,15 @@ export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Pro
|
|||||||
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A POST whose body is sent as it is (a file, or text already serialised),
|
||||||
|
* under the same deadline and error handling as every other call. */
|
||||||
|
export function apiPostRaw<T>(
|
||||||
|
path: string, body: BodyInit | null, contentType: string | null, opts?: RequestOpts,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers = contentType ? { "Content-Type": contentType } : undefined;
|
||||||
|
return request<T>(path, { method: "POST", headers, body }, opts);
|
||||||
|
}
|
||||||
|
|
||||||
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||||
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export interface RemovedDefault {
|
|||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MomentRuleUsage {
|
||||||
|
rule_id: number;
|
||||||
|
/** Null for a rule deleted since it was delivered. */
|
||||||
|
title: string | null;
|
||||||
|
delivered: number;
|
||||||
|
opened: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MomentUsage {
|
export interface MomentUsage {
|
||||||
/** How many times a mounted rule arrived at this moment. */
|
/** How many times a mounted rule arrived at this moment. */
|
||||||
delivered: number;
|
delivered: number;
|
||||||
@@ -53,6 +61,8 @@ export interface MomentUsage {
|
|||||||
* the door, not the line that prompted it. */
|
* the door, not the line that prompted it. */
|
||||||
opened: number;
|
opened: number;
|
||||||
last_delivered_at: string | null;
|
last_delivered_at: string | null;
|
||||||
|
/** Each rule delivered here, most delivered first. */
|
||||||
|
by_rule: MomentRuleUsage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MomentsPayload {
|
export interface MomentsPayload {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
/* The three typefaces the design system names: Fraunces for display, Inter for
|
||||||
|
body text, JetBrains Mono for code. They are served from the app itself, so a
|
||||||
|
page never asks a font host for anything and an instance with no internet
|
||||||
|
still draws in its own type.
|
||||||
|
|
||||||
|
Every subset Google Fonts publishes for these families is here, not only
|
||||||
|
latin. Each face carries a unicode-range, so a browser downloads only the
|
||||||
|
subsets the page's text uses.
|
||||||
|
|
||||||
|
All three are under the SIL Open Font License 1.1; the OFL-*.txt files beside
|
||||||
|
this one are their licences. */
|
||||||
|
|
||||||
|
/* Fraunces, italic, vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Fraunces';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./fraunces-italic-vietnamese.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fraunces, italic, latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Fraunces';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./fraunces-italic-latin-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fraunces, italic, latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Fraunces';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./fraunces-italic-latin.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fraunces, normal, vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Fraunces';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./fraunces-normal-vietnamese.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fraunces, normal, latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Fraunces';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./fraunces-normal-latin-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fraunces, normal, latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Fraunces';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./fraunces-normal-latin.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, italic, cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-italic-cyrillic-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, italic, cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-italic-cyrillic.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, italic, greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-italic-greek-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, italic, greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-italic-greek.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, italic, vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-italic-vietnamese.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, italic, latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-italic-latin-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, italic, latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-italic-latin.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-cyrillic-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-cyrillic.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-greek-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-greek.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-vietnamese.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-latin-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-latin.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-cyrillic-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-cyrillic.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, greek-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-greek-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+1F00-1FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-greek.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-vietnamese.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-latin-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inter, normal, latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./inter-normal-latin.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, italic, cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-italic-cyrillic-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, italic, cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-italic-cyrillic.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, italic, greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-italic-greek.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, italic, vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-italic-vietnamese.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, italic, latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-italic-latin-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, italic, latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-italic-latin.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, normal, cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-normal-cyrillic-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, normal, cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-normal-cyrillic.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, normal, greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-normal-greek.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, normal, vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-normal-vietnamese.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, normal, latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-normal-latin-ext.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JetBrains Mono, normal, latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./jetbrains-mono-normal-latin.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,3 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:ital,wght@0,400;1,400&display=swap');
|
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
GENERATED FROM THE DESIGN SYSTEM — Grimoire (design system 2), which inherits
|
GENERATED FROM THE DESIGN SYSTEM — Grimoire (design system 2), which inherits
|
||||||
the FabledSword house style (design system 1).
|
the FabledSword house style (design system 1).
|
||||||
@@ -8,13 +6,13 @@
|
|||||||
and regenerate: /design-systems -> Master stylesheet -> Copy.
|
and regenerate: /design-systems -> Master stylesheet -> Copy.
|
||||||
|
|
||||||
DARK IS THE BASE LAYER. The kit is dark-mode-first, so :root carries the dark
|
DARK IS THE BASE LAYER. The kit is dark-mode-first, so :root carries the dark
|
||||||
palette and [data-theme="light"] overrides it. That is the inverse of how this
|
palette and [data-theme="light"] overrides it. No rule specifies the light
|
||||||
file used to read, and it is deliberate: the light palette was never specified
|
palette, so it is written as an override of the dark one rather than as the
|
||||||
by any rule, so it is recorded as a departure rather than as the default.
|
default.
|
||||||
|
|
||||||
The -fg tokens are a badge's TEXT colour, added because the ladder used its
|
The -fg tokens are a badge's TEXT colour. A raw hue as text on a 12% tint of
|
||||||
raw hue as text on a 12% tint of the same hue — measured 1.60-2.97:1 on the
|
the same hue measures 1.60-2.97:1 on the dark palette, under the kit's AA
|
||||||
dark palette against the kit's AA floor of 4.5. Each is the hue mixed toward
|
floor of 4.5, so each -fg token is the hue mixed toward
|
||||||
--fs-text-primary until it clears 4.5:1 worst-case over surface-raised and
|
--fs-text-primary until it clears 4.5:1 worst-case over surface-raised and
|
||||||
surface-hover in BOTH modes. Mixing toward that token is what makes one
|
surface-hover in BOTH modes. Mixing toward that token is what makes one
|
||||||
declaration cover both: it inverts, so the text follows the mode.
|
declaration cover both: it inverts, so the text follows the mode.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ defineProps<{ size?: number }>();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- The clasped grimoire, the same drawing as public/favicon.svg. Its colours
|
||||||
|
are the mark's own and stay fixed in both themes, like the tab icon. -->
|
||||||
<svg
|
<svg
|
||||||
class="app-logo"
|
class="app-logo"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -11,45 +13,21 @@ defineProps<{ size?: number }>();
|
|||||||
:height="size ?? 24"
|
:height="size ?? 24"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<defs>
|
<!-- page block, seen at the fore-edge -->
|
||||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
|
<path d="M8.5 4.5 H25.5 Q27 4.5 27 6 V27 Q27 28.5 25.5 28.5 H8.5 Z" fill="#E9DFC6"/>
|
||||||
<stop offset="0%" stop-color="var(--fs-accent)" />
|
<path d="M25 7 V26.5" stroke="#C9BB97" stroke-width="0.6"/>
|
||||||
<stop offset="100%" stop-color="var(--fs-accent-deep)" />
|
<!-- cover -->
|
||||||
</linearGradient>
|
<path d="M5 4 Q5 3 6 3 H24 Q25.5 3 25.5 4.5 V26.5 Q25.5 28 24 28 H6 Q5 28 5 27 Z" fill="#5B4A8A"/>
|
||||||
</defs>
|
<!-- spine band -->
|
||||||
<!-- Book body -->
|
<rect x="5" y="3" width="3.2" height="25" rx="0.6" fill="#47396E"/>
|
||||||
<path class="logo-book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/>
|
<!-- corner fittings -->
|
||||||
<!-- Book spine -->
|
<path d="M21.5 3 H24 Q25.5 3 25.5 4.5 V7 Z" fill="#D4A94E"/>
|
||||||
<line class="logo-spine" x1="16" y1="6" x2="16" y2="25" stroke-width="0.8"/>
|
<path d="M21.5 28 H24 Q25.5 28 25.5 26.5 V24 Z" fill="#D4A94E"/>
|
||||||
<!-- Left page lines -->
|
<!-- clasp across the fore-edge -->
|
||||||
<line class="logo-lines" x1="7" y1="11" x2="14" y2="10.5" stroke-width="0.6" stroke-linecap="round"/>
|
<rect x="22.5" y="14" width="5.5" height="3.6" rx="0.8" fill="#D4A94E"/>
|
||||||
<line class="logo-lines" x1="7" y1="14" x2="14" y2="13.5" stroke-width="0.6" stroke-linecap="round"/>
|
<circle cx="26.4" cy="15.8" r="0.7" fill="#8A6A22"/>
|
||||||
<line class="logo-lines" x1="7" y1="17" x2="14" y2="16.5" stroke-width="0.6" stroke-linecap="round"/>
|
<!-- sigil: a four-pointed star in a ring -->
|
||||||
<line class="logo-lines" x1="7" y1="20" x2="12" y2="19.5" stroke-width="0.6" stroke-linecap="round"/>
|
<circle cx="15.5" cy="15.5" r="5.2" fill="none" stroke="#D4A94E" stroke-width="0.9"/>
|
||||||
<!-- Right page lines -->
|
<path d="M15.5 11.4 L16.5 14.5 L19.6 15.5 L16.5 16.5 L15.5 19.6 L14.5 16.5 L11.4 15.5 L14.5 14.5 Z" fill="#F1D48A"/>
|
||||||
<line class="logo-lines" x1="18" y1="10.5" x2="25" y2="11" stroke-width="0.6" stroke-linecap="round"/>
|
|
||||||
<line class="logo-lines" x1="18" y1="13.5" x2="25" y2="14" stroke-width="0.6" stroke-linecap="round"/>
|
|
||||||
<line class="logo-lines" x1="18" y1="16.5" x2="25" y2="17" stroke-width="0.6" stroke-linecap="round"/>
|
|
||||||
<!-- Magic spark -->
|
|
||||||
<g transform="translate(24, 5)">
|
|
||||||
<path d="M0 -4 L1 -1 L4 0 L1 1 L0 4 L-1 1 L-4 0 L-1 -1 Z" fill="#f6ad55"/>
|
|
||||||
<path d="M0 -2.5 L0.6 -0.6 L2.5 0 L0.6 0.6 L0 2.5 L-0.6 0.6 L-2.5 0 L-0.6 -0.6 Z" fill="#fbd38d"/>
|
|
||||||
</g>
|
|
||||||
<!-- Secondary sparkles -->
|
|
||||||
<circle cx="20" cy="3" r="0.7" fill="#f6ad55" opacity="0.7"/>
|
|
||||||
<circle cx="27" cy="8" r="0.5" fill="#fbd38d" opacity="0.6"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.logo-book {
|
|
||||||
fill: url(#logo-gradient);
|
|
||||||
stroke: color-mix(in srgb, var(--fs-accent) 70%, transparent);
|
|
||||||
}
|
|
||||||
.logo-spine {
|
|
||||||
stroke: var(--fs-text-secondary);
|
|
||||||
}
|
|
||||||
.logo-lines {
|
|
||||||
stroke: var(--fs-text-tertiary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,466 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from "vue";
|
|
||||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
|
||||||
import DiffView from "@/components/DiffView.vue";
|
|
||||||
import { computeDiff, type DiffLine } from "@/utils/diff";
|
|
||||||
import { fmtStamp } from "@/utils/dateFormat";
|
|
||||||
|
|
||||||
interface NoteVersion {
|
|
||||||
id: number;
|
|
||||||
note_id: number;
|
|
||||||
title: string;
|
|
||||||
tags: string[];
|
|
||||||
body?: string;
|
|
||||||
pin_kind: "auto" | "manual" | null;
|
|
||||||
pin_label: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
noteId: number;
|
|
||||||
currentBody: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'restore', body: string, tags: string[]): void;
|
|
||||||
(e: 'close'): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const versions = ref<NoteVersion[]>([]);
|
|
||||||
const selectedVersion = ref<NoteVersion | null>(null);
|
|
||||||
const loading = ref(false);
|
|
||||||
const loadingDetail = ref(false);
|
|
||||||
|
|
||||||
const diff = computed<DiffLine[]>(() => {
|
|
||||||
if (!selectedVersion.value?.body) return [];
|
|
||||||
|
|
||||||
return computeDiff(props.currentBody, selectedVersion.value.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadVersions() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const data = await apiGet<{ versions: NoteVersion[] }>(
|
|
||||||
`/api/notes/${props.noteId}/versions`
|
|
||||||
);
|
|
||||||
versions.value = data.versions;
|
|
||||||
if (versions.value.length > 0) {
|
|
||||||
await selectVersionItem(versions.value[0]);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectVersionItem(v: NoteVersion) {
|
|
||||||
if (v.body !== undefined) {
|
|
||||||
selectedVersion.value = v;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadingDetail.value = true;
|
|
||||||
try {
|
|
||||||
const full = await apiGet<NoteVersion>(
|
|
||||||
`/api/notes/${props.noteId}/versions/${v.id}`
|
|
||||||
);
|
|
||||||
// Cache body back onto list item
|
|
||||||
const listItem = versions.value.find(x => x.id === v.id);
|
|
||||||
if (listItem) listItem.body = full.body;
|
|
||||||
selectedVersion.value = full;
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
} finally {
|
|
||||||
loadingDetail.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function restore() {
|
|
||||||
if (!selectedVersion.value?.body) return;
|
|
||||||
emit('restore', selectedVersion.value.body, selectedVersion.value.tags ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pin / unpin / edit-label state ──────────────────────────────────────────
|
|
||||||
|
|
||||||
const editingLabel = ref(false);
|
|
||||||
const draftLabel = ref("");
|
|
||||||
const pinSaving = ref(false);
|
|
||||||
|
|
||||||
function startEditLabel() {
|
|
||||||
if (!selectedVersion.value) return;
|
|
||||||
draftLabel.value = selectedVersion.value.pin_label ?? "";
|
|
||||||
editingLabel.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEditLabel() {
|
|
||||||
editingLabel.value = false;
|
|
||||||
draftLabel.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePin() {
|
|
||||||
if (!selectedVersion.value || pinSaving.value) return;
|
|
||||||
pinSaving.value = true;
|
|
||||||
try {
|
|
||||||
const updated = await pinNoteVersion(
|
|
||||||
props.noteId, selectedVersion.value.id, draftLabel.value || null,
|
|
||||||
);
|
|
||||||
// Reflect server state in the local list + selected version.
|
|
||||||
const idx = versions.value.findIndex(v => v.id === updated.id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
versions.value[idx].pin_kind = updated.pin_kind;
|
|
||||||
versions.value[idx].pin_label = updated.pin_label;
|
|
||||||
}
|
|
||||||
if (selectedVersion.value) {
|
|
||||||
selectedVersion.value.pin_kind = updated.pin_kind;
|
|
||||||
selectedVersion.value.pin_label = updated.pin_label;
|
|
||||||
}
|
|
||||||
editingLabel.value = false;
|
|
||||||
} catch {
|
|
||||||
// silent — leaving the form open lets the user retry
|
|
||||||
} finally {
|
|
||||||
pinSaving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unpinSelected() {
|
|
||||||
if (!selectedVersion.value || pinSaving.value) return;
|
|
||||||
pinSaving.value = true;
|
|
||||||
try {
|
|
||||||
await unpinNoteVersion(props.noteId, selectedVersion.value.id);
|
|
||||||
const idx = versions.value.findIndex(v => v.id === selectedVersion.value!.id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
versions.value[idx].pin_kind = null;
|
|
||||||
versions.value[idx].pin_label = null;
|
|
||||||
}
|
|
||||||
if (selectedVersion.value) {
|
|
||||||
selectedVersion.value.pin_kind = null;
|
|
||||||
selectedVersion.value.pin_label = null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
} finally {
|
|
||||||
pinSaving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadVersions);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<teleport to="body">
|
|
||||||
<div class="modal-overlay" @click.self="emit('close')">
|
|
||||||
<div class="modal-card history-modal">
|
|
||||||
<div class="history-header">
|
|
||||||
<h3 class="history-title">Version History</h3>
|
|
||||||
<button class="history-close" aria-label="Close version history" @click="emit('close')">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="history-body">
|
|
||||||
<!-- Left: version list -->
|
|
||||||
<div class="history-list">
|
|
||||||
<div v-if="loading" class="history-empty">Loading...</div>
|
|
||||||
<div v-else-if="!versions.length" class="history-empty">No versions yet.</div>
|
|
||||||
<div
|
|
||||||
v-for="v in versions"
|
|
||||||
:key="v.id"
|
|
||||||
:class="['history-item', { selected: selectedVersion?.id === v.id }]"
|
|
||||||
@click="selectVersionItem(v)"
|
|
||||||
>
|
|
||||||
<div class="history-item-title">
|
|
||||||
<span
|
|
||||||
v-if="v.pin_kind === 'manual'"
|
|
||||||
class="pin-badge pin-badge-manual"
|
|
||||||
:title="v.pin_label || 'Pinned'"
|
|
||||||
aria-label="manually pinned"
|
|
||||||
>●</span>
|
|
||||||
<span
|
|
||||||
v-else-if="v.pin_kind === 'auto'"
|
|
||||||
class="pin-badge pin-badge-auto"
|
|
||||||
:title="v.pin_label || 'Auto-pinned (stable)'"
|
|
||||||
aria-label="auto-pinned"
|
|
||||||
>◐</span>
|
|
||||||
{{ v.title || 'Untitled' }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="v.pin_kind === 'manual' && v.pin_label"
|
|
||||||
class="history-item-label"
|
|
||||||
>{{ v.pin_label }}</div>
|
|
||||||
<div class="history-item-date">{{ fmtStamp(v.created_at) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: diff -->
|
|
||||||
<div class="history-diff">
|
|
||||||
<div v-if="loadingDetail" class="history-empty">Loading version...</div>
|
|
||||||
<div v-else-if="!selectedVersion" class="history-empty">Select a version to compare.</div>
|
|
||||||
<template v-else>
|
|
||||||
<div class="version-pin-controls">
|
|
||||||
<!-- Label editor -->
|
|
||||||
<div v-if="editingLabel" class="pin-label-form">
|
|
||||||
<input
|
|
||||||
v-model="draftLabel"
|
|
||||||
maxlength="500"
|
|
||||||
type="text"
|
|
||||||
class="pin-label-input"
|
|
||||||
placeholder="Optional label (e.g. 'post-network-refresh runbook')"
|
|
||||||
/>
|
|
||||||
<button class="btn-pin-save" :disabled="pinSaving" @click="savePin">
|
|
||||||
{{ pinSaving ? "Saving…" : "Save" }}
|
|
||||||
</button>
|
|
||||||
<button class="btn-pin-cancel" @click="cancelEditLabel">Cancel</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Default: kind-aware action row -->
|
|
||||||
<div v-else class="pin-actions">
|
|
||||||
<span v-if="selectedVersion.pin_kind === 'manual'" class="pin-state">
|
|
||||||
<span class="pin-badge pin-badge-manual">●</span>
|
|
||||||
Manually pinned{{ selectedVersion.pin_label ? `: ${selectedVersion.pin_label}` : '' }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="selectedVersion.pin_kind === 'auto'" class="pin-state">
|
|
||||||
<span class="pin-badge pin-badge-auto">◐</span>
|
|
||||||
Auto-pinned{{ selectedVersion.pin_label ? `: ${selectedVersion.pin_label}` : '' }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="selectedVersion.pin_kind === null"
|
|
||||||
class="btn-pin"
|
|
||||||
:disabled="pinSaving"
|
|
||||||
@click="startEditLabel"
|
|
||||||
>Pin version</button>
|
|
||||||
<button
|
|
||||||
v-else-if="selectedVersion.pin_kind === 'manual'"
|
|
||||||
class="btn-pin-edit"
|
|
||||||
:disabled="pinSaving"
|
|
||||||
@click="startEditLabel"
|
|
||||||
>Edit label</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="btn-pin"
|
|
||||||
:disabled="pinSaving"
|
|
||||||
@click="startEditLabel"
|
|
||||||
>Pin permanently</button>
|
|
||||||
<button
|
|
||||||
v-if="selectedVersion.pin_kind === 'manual'"
|
|
||||||
class="btn-unpin"
|
|
||||||
:disabled="pinSaving"
|
|
||||||
@click="unpinSelected"
|
|
||||||
>Unpin</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DiffView :diff="diff" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="history-footer">
|
|
||||||
<button
|
|
||||||
class="btn-primary"
|
|
||||||
:disabled="!selectedVersion?.body"
|
|
||||||
@click="restore"
|
|
||||||
>Restore this version</button>
|
|
||||||
<button class="modal-btn" @click="emit('close')">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.history-modal {
|
|
||||||
max-width: 900px;
|
|
||||||
width: 95%;
|
|
||||||
height: 80vh;
|
|
||||||
max-height: 700px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-header {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.9rem 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0.1rem 0.3rem;
|
|
||||||
}
|
|
||||||
.history-close:hover { color: var(--fs-text-primary); }
|
|
||||||
|
|
||||||
.history-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-list {
|
|
||||||
width: 220px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-right: 1px solid var(--fs-border-color);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-item {
|
|
||||||
padding: 0.6rem 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
|
||||||
}
|
|
||||||
.history-item:hover { background: var(--fs-surface-raised); }
|
|
||||||
.history-item.selected {
|
|
||||||
border-left-color: var(--fs-accent);
|
|
||||||
background: var(--fs-surface-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-item-title {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--fs-text-primary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-item-date {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-diff {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-empty {
|
|
||||||
padding: 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-footer {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
border-top: 1px solid var(--fs-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Pin badges + label rendering ───────────────────────────────────────── */
|
|
||||||
.pin-badge {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0.7rem;
|
|
||||||
text-align: center;
|
|
||||||
margin-right: 0.35rem;
|
|
||||||
font-size: 0.85em;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.pin-badge-manual { color: var(--fs-accent); }
|
|
||||||
.pin-badge-auto { color: var(--fs-text-tertiary); }
|
|
||||||
|
|
||||||
.history-item-label {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--fs-accent);
|
|
||||||
font-style: italic;
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Pin controls above the diff ────────────────────────────────────────── */
|
|
||||||
.version-pin-controls {
|
|
||||||
padding: 0.4rem 0.5rem 0.5rem;
|
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
.pin-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.pin-state {
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.btn-pin, .btn-pin-edit, .btn-unpin {
|
|
||||||
padding: 0.25rem 0.7rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
border-radius: 999px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
|
||||||
background: rgba(99, 102, 241, 0.12);
|
|
||||||
border-color: var(--fs-accent);
|
|
||||||
}
|
|
||||||
.btn-unpin:hover:not(:disabled) {
|
|
||||||
background: rgba(239, 68, 68, 0.10);
|
|
||||||
border-color: rgba(239, 68, 68, 0.5);
|
|
||||||
}
|
|
||||||
.pin-label-form {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.pin-label-input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.3rem 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
background: var(--fs-surface-page);
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
border-radius: var(--fs-radius-sm);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
.pin-label-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--fs-accent);
|
|
||||||
}
|
|
||||||
.btn-pin-save, .btn-pin-cancel {
|
|
||||||
padding: 0.3rem 0.7rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
border-radius: var(--fs-radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-pin-save:hover:not(:disabled) {
|
|
||||||
background: rgba(99, 102, 241, 0.12);
|
|
||||||
border-color: var(--fs-accent);
|
|
||||||
}
|
|
||||||
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
|
||||||
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: progress;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from "vue";
|
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
|
||||||
import type { DiffLine } from "@/composables/useAssist";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
phase: "streaming" | "review";
|
|
||||||
label: string;
|
|
||||||
streamingText: string;
|
|
||||||
diff: DiffLine[];
|
|
||||||
proposedText: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
accept: [];
|
|
||||||
reject: [];
|
|
||||||
cancel: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const showFullText = ref(false);
|
|
||||||
|
|
||||||
const renderedStreaming = computed(() => renderMarkdown(props.streamingText));
|
|
||||||
const renderedProposed = computed(() => renderMarkdown(props.proposedText));
|
|
||||||
|
|
||||||
const markers: Record<DiffLine["type"], string> = {
|
|
||||||
equal: " ",
|
|
||||||
delete: "−",
|
|
||||||
insert: "+",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- ── Streaming ───────────────────────────────────────────────── -->
|
|
||||||
<div v-if="phase === 'streaming'" class="iap iap-streaming">
|
|
||||||
<div class="iap-header">
|
|
||||||
<span class="iap-pulse"></span>
|
|
||||||
<span class="iap-label">{{ label }}</span>
|
|
||||||
<button class="iap-btn-cancel" @click="emit('cancel')">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<div class="iap-stream-preview prose" v-html="renderedStreaming"></div>
|
|
||||||
<div v-if="!streamingText" class="iap-waiting">Waiting for model…</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Review ─────────────────────────────────────────────────── -->
|
|
||||||
<div v-else-if="phase === 'review'" class="iap iap-review">
|
|
||||||
<div class="iap-header">
|
|
||||||
<span class="iap-review-title">Proposed changes</span>
|
|
||||||
<button class="iap-btn-toggle" @click="showFullText = !showFullText">
|
|
||||||
{{ showFullText ? "Show diff" : "Show full text" }}
|
|
||||||
</button>
|
|
||||||
<div class="iap-actions">
|
|
||||||
<button class="iap-btn-accept" @click="emit('accept')">✓ Accept</button>
|
|
||||||
<button class="iap-btn-reject" @click="emit('reject')">✗ Reject</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Diff view -->
|
|
||||||
<div v-if="!showFullText" class="iap-diff">
|
|
||||||
<div
|
|
||||||
v-for="(line, i) in diff"
|
|
||||||
:key="i"
|
|
||||||
:class="['iap-diff-line', `iap-diff-${line.type}`]"
|
|
||||||
>
|
|
||||||
<span class="iap-diff-marker">{{ markers[line.type] }}</span>
|
|
||||||
<span class="iap-diff-text">{{ line.text }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="!diff.length" class="iap-diff-empty">No changes.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Full text view -->
|
|
||||||
<div v-else class="iap-full-text prose" v-html="renderedProposed"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.iap {
|
|
||||||
border-radius: var(--fs-radius-sm);
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
background: var(--fs-surface-page);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header ── */
|
|
||||||
.iap-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.45rem 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
|
||||||
background: var(--fs-surface-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Streaming ── */
|
|
||||||
.iap-streaming {
|
|
||||||
border-color: var(--fs-accent);
|
|
||||||
}
|
|
||||||
.iap-streaming .iap-header {
|
|
||||||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-pulse {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--fs-accent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
animation: iap-pulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes iap-pulse {
|
|
||||||
0%, 100% { opacity: 1; transform: scale(1); }
|
|
||||||
50% { opacity: 0.4; transform: scale(0.8); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-label {
|
|
||||||
flex: 1;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--fs-text-primary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-btn-cancel {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
color: var(--fs-text-secondary);
|
|
||||||
border-radius: var(--fs-radius-sm);
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.iap-btn-cancel:hover {
|
|
||||||
border-color: var(--fs-error);
|
|
||||||
color: var(--fs-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-stream-preview {
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
max-height: 320px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-waiting {
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Review ── */
|
|
||||||
.iap-review-title {
|
|
||||||
flex: 1;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--fs-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-btn-toggle {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
color: var(--fs-text-secondary);
|
|
||||||
border-radius: var(--fs-radius-sm);
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.iap-btn-toggle:hover {
|
|
||||||
border-color: var(--fs-accent);
|
|
||||||
color: var(--fs-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-btn-accept,
|
|
||||||
.iap-btn-reject {
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--fs-radius-sm);
|
|
||||||
padding: 0.2rem 0.65rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: var(--fs-weight-medium);
|
|
||||||
}
|
|
||||||
.iap-btn-accept {
|
|
||||||
background: var(--fs-success);
|
|
||||||
color: var(--fs-text-on-action);
|
|
||||||
}
|
|
||||||
.iap-btn-accept:hover { opacity: 0.85; }
|
|
||||||
|
|
||||||
.iap-btn-reject {
|
|
||||||
background: var(--fs-surface-raised);
|
|
||||||
color: var(--fs-text-secondary);
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
}
|
|
||||||
.iap-btn-reject:hover {
|
|
||||||
border-color: var(--fs-error);
|
|
||||||
color: var(--fs-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Diff ── */
|
|
||||||
.iap-diff {
|
|
||||||
font-family: ui-monospace, "Cascadia Code", "Fira Mono", monospace;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
max-height: 440px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.4rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-diff-line {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-diff-equal { color: var(--fs-text-tertiary); }
|
|
||||||
.iap-diff-delete {
|
|
||||||
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
|
||||||
color: var(--fs-error-fg);
|
|
||||||
}
|
|
||||||
.iap-diff-insert {
|
|
||||||
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
|
|
||||||
color: var(--fs-success-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-diff-marker {
|
|
||||||
width: 1ch;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 700;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iap-diff-empty {
|
|
||||||
padding: 0.75rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Full text ── */
|
|
||||||
.iap-full-text {
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
max-height: 440px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MomentRuleUsage } from "@/api/moments";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which rules a moment delivered, and whether an agent went on to read each.
|
||||||
|
* The moment's own counts cannot say this once two rules are mounted there.
|
||||||
|
* Closed by default: the counts in the row above are what most visits want.
|
||||||
|
*/
|
||||||
|
defineProps<{ rules: MomentRuleUsage[] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<details v-if="rules.length" class="moment-rules">
|
||||||
|
<summary>Rules delivered here</summary>
|
||||||
|
<ul class="delivered-list">
|
||||||
|
<li v-for="r in rules" :key="r.rule_id" class="delivered-row">
|
||||||
|
<span class="delivered-title">{{ r.title ?? `Rule ${r.rule_id} (deleted)` }}</span>
|
||||||
|
<span class="delivered-count">delivered {{ r.delivered }}</span>
|
||||||
|
<span :class="r.opened ? 'delivered-opened' : 'delivered-unopened'">
|
||||||
|
{{ r.opened ? "opened" : "not opened" }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.moment-rules { margin-top: var(--fs-space-2); font-size: var(--fs-size-tiny); }
|
||||||
|
.moment-rules summary { cursor: pointer; color: var(--fs-text-tertiary); }
|
||||||
|
.delivered-list { list-style: none; margin: var(--fs-space-1) 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-1); }
|
||||||
|
.delivered-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); font-variant-numeric: tabular-nums; }
|
||||||
|
.delivered-title { color: var(--fs-text-primary); font-size: var(--fs-size-body-sm); }
|
||||||
|
.delivered-count { color: var(--fs-text-secondary); }
|
||||||
|
.delivered-opened { color: var(--fs-text-secondary); }
|
||||||
|
/* Not read is a fact about this window, not a fault: quiet, like "nothing mounted". */
|
||||||
|
.delivered-unopened { color: var(--fs-text-tertiary); font-style: italic; }
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useMomentsStore } from "@/stores/moments";
|
import { useMomentsStore } from "@/stores/moments";
|
||||||
import MomentProposals from "@/components/MomentProposals.vue";
|
import MomentProposals from "@/components/MomentProposals.vue";
|
||||||
|
import MomentRuleUsage from "@/components/MomentRuleUsage.vue";
|
||||||
import type { MomentAction, MomentUsage } from "@/api/moments";
|
import type { MomentAction, MomentUsage } from "@/api/moments";
|
||||||
import { fmtDate } from "@/utils/dateFormat";
|
import { fmtDate } from "@/utils/dateFormat";
|
||||||
|
|
||||||
@@ -167,6 +168,7 @@ onMounted(() => store.load(true));
|
|||||||
>×</button>
|
>×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<MomentRuleUsage v-if="usage(m.name)" :rules="usage(m.name)!.by_rule" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -186,6 +188,7 @@ onMounted(() => store.load(true));
|
|||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<MomentRuleUsage v-if="usage(name)" :rules="usage(name)!.by_rule" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import type { Note } from "@/types/note";
|
|
||||||
import TagPill from "@/components/TagPill.vue";
|
|
||||||
import { relativeTime } from "@/composables/useRelativeTime";
|
|
||||||
import { renderPreview } from "@/utils/markdown";
|
|
||||||
|
|
||||||
const props = defineProps<{ note: Note; compact?: boolean }>();
|
|
||||||
const emit = defineEmits<{ "tag-click": [tag: string] }>();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
function onTagClick(tag: string) {
|
|
||||||
emit("tag-click", tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goEdit() {
|
|
||||||
router.push(`/notes/${props.note.id}/edit`);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<router-link :to="`/notes/${note.id}`" :class="['note-card', { compact }]">
|
|
||||||
|
|
||||||
<!-- Compact: single row -->
|
|
||||||
<template v-if="compact">
|
|
||||||
<span class="note-title-compact">{{ note.title || "Untitled" }}</span>
|
|
||||||
<div class="note-tags-compact">
|
|
||||||
<TagPill
|
|
||||||
v-for="tag in note.tags.slice(0, 3)"
|
|
||||||
:key="tag"
|
|
||||||
:tag="tag"
|
|
||||||
@click.stop="onTagClick(tag)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="timestamp-compact">{{ relativeTime(note.updated_at) }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Full: original layout -->
|
|
||||||
<template v-else>
|
|
||||||
<div class="note-top">
|
|
||||||
<h3 class="note-title">{{ note.title || "Untitled" }}</h3>
|
|
||||||
<button class="btn-edit" title="Edit" @click.prevent.stop="goEdit">Edit</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="note.body" class="note-preview prose" v-html="renderPreview(note.body)"></div>
|
|
||||||
<div class="note-meta">
|
|
||||||
<TagPill
|
|
||||||
v-for="tag in note.tags"
|
|
||||||
:key="tag"
|
|
||||||
:tag="tag"
|
|
||||||
@click.stop="onTagClick(tag)"
|
|
||||||
/>
|
|
||||||
<span class="timestamp">{{ relativeTime(note.updated_at) }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</router-link>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.note-card {
|
|
||||||
display: block;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--fs-radius-lg);
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
background: var(--fs-surface-raised);
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
|
|
||||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
|
||||||
}
|
|
||||||
.note-card:hover {
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Compact single-row layout */
|
|
||||||
.note-card.compact {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.45rem 0.75rem;
|
|
||||||
background: var(--fs-surface-raised);
|
|
||||||
box-shadow: none;
|
|
||||||
border-radius: 0;
|
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
.note-card.compact:first-child {
|
|
||||||
border-top: 1px solid var(--fs-border-color);
|
|
||||||
}
|
|
||||||
.note-card.compact:hover {
|
|
||||||
box-shadow: none;
|
|
||||||
background: color-mix(in srgb, var(--fs-accent) 4%, transparent);
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
.note-title-compact {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.note-tags-compact {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.3rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.timestamp-compact {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Full layout */
|
|
||||||
.note-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
.note-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.btn-edit {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background: var(--fs-surface-raised);
|
|
||||||
color: var(--fs-text-secondary);
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
border-radius: var(--fs-radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.btn-edit:hover {
|
|
||||||
color: var(--fs-accent);
|
|
||||||
border-color: var(--fs-accent);
|
|
||||||
}
|
|
||||||
.note-preview {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
color: var(--fs-text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
max-height: 7.5em;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
|
|
||||||
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
|
|
||||||
}
|
|
||||||
.note-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.timestamp {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Restore: put the whole instance back to a full backup.
|
||||||
|
*
|
||||||
|
* Choose a backup (one at the backup destination, or a file from this
|
||||||
|
* computer), see what it would change, type the confirmation, run it. On a
|
||||||
|
* live instance this is a rollback to the instance's own earlier backup; on a
|
||||||
|
* new, empty one it takes the backup in. Either way the server backs up the
|
||||||
|
* current state first and signs everyone out afterwards.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import {
|
||||||
|
backupTakenAt, listRestoreSources, previewRestore, restoreBackup,
|
||||||
|
type RestoreFrom, type RestorePreview, type RestoreResult, type StoredBackup,
|
||||||
|
} from "@/api/backups";
|
||||||
|
import { formatBytes } from "@/utils/bytes";
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const CONFIRM_WORDS = "roll back";
|
||||||
|
|
||||||
|
type Step = "choose" | "checking" | "preview" | "running" | "done";
|
||||||
|
const step = ref<Step>("choose");
|
||||||
|
const sources = ref<StoredBackup[]>([]);
|
||||||
|
const sourcesError = ref("");
|
||||||
|
const sourcesLoading = ref(true);
|
||||||
|
const chosen = ref<RestoreFrom | null>(null);
|
||||||
|
const chosenLabel = ref("");
|
||||||
|
const preview = ref<RestorePreview | null>(null);
|
||||||
|
const result = ref<RestoreResult | null>(null);
|
||||||
|
const error = ref("");
|
||||||
|
const typed = ref("");
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const confirmed = computed(() => typed.value.trim().toLowerCase() === CONFIRM_WORDS);
|
||||||
|
|
||||||
|
function when(value: Date | string | null | undefined): string {
|
||||||
|
if (!value) return "an unknown date";
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
return Number.isNaN(date.getTime()) ? "an unknown date" : date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const answer = await listRestoreSources();
|
||||||
|
sources.value = answer.backups;
|
||||||
|
sourcesError.value = answer.error ?? "";
|
||||||
|
} catch (e) {
|
||||||
|
sourcesError.value = apiErrorMessage(e, "Could not list the backups at the destination.");
|
||||||
|
} finally {
|
||||||
|
sourcesLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function check(from: RestoreFrom, label: string) {
|
||||||
|
chosen.value = from;
|
||||||
|
chosenLabel.value = label;
|
||||||
|
error.value = "";
|
||||||
|
typed.value = "";
|
||||||
|
step.value = "checking";
|
||||||
|
try {
|
||||||
|
preview.value = await previewRestore(from);
|
||||||
|
step.value = "preview";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiErrorMessage(e, "That backup could not be read.");
|
||||||
|
step.value = "choose";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFile(event: Event) {
|
||||||
|
const file = (event.target as HTMLInputElement).files?.[0];
|
||||||
|
if (fileInput.value) fileInput.value.value = "";
|
||||||
|
if (file) check({ file }, file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!chosen.value || !confirmed.value) return;
|
||||||
|
error.value = "";
|
||||||
|
step.value = "running";
|
||||||
|
try {
|
||||||
|
result.value = await restoreBackup(chosen.value);
|
||||||
|
step.value = "done";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiErrorMessage(e, "The restore failed. Nothing was changed.");
|
||||||
|
step.value = "preview";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
// Every session ended with the restore, this one included.
|
||||||
|
window.location.assign("/login");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="modal-overlay" @click="step === 'running' || step === 'done' ? null : emit('close')">
|
||||||
|
<div class="modal-card restore-card" @click.stop>
|
||||||
|
<template v-if="step === 'choose' || step === 'checking'">
|
||||||
|
<h3 class="modal-title">Restore from a backup</h3>
|
||||||
|
<p class="modal-message">
|
||||||
|
Puts the whole instance back to the moment the backup was taken, for every user.
|
||||||
|
You'll see what changes before anything happens.
|
||||||
|
</p>
|
||||||
|
<p v-if="error" class="restore-error" role="alert">{{ error }}</p>
|
||||||
|
|
||||||
|
<h4 class="restore-subhead">At the backup destination</h4>
|
||||||
|
<p v-if="sourcesLoading" class="restore-muted">Looking for backups…</p>
|
||||||
|
<p v-else-if="sourcesError" class="restore-muted">{{ sourcesError }}</p>
|
||||||
|
<p v-else-if="!sources.length" class="restore-muted">No backups there yet.</p>
|
||||||
|
<ul v-else class="restore-sources">
|
||||||
|
<li v-for="b in sources" :key="b.name">
|
||||||
|
<button
|
||||||
|
class="restore-source"
|
||||||
|
:disabled="step === 'checking'"
|
||||||
|
@click="check({ source: b.name }, when(backupTakenAt(b.name)))"
|
||||||
|
>
|
||||||
|
<span>{{ when(backupTakenAt(b.name)) }}</span>
|
||||||
|
<span class="restore-muted">{{ formatBytes(b.size_bytes) }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 class="restore-subhead">From this computer</h4>
|
||||||
|
<p class="restore-muted">
|
||||||
|
A backup file (.tar) or the Full Backup download (.json). Uploads are size-limited;
|
||||||
|
a large file restores from the destination above, or with
|
||||||
|
<code>python -m grimoire.restore</code>.
|
||||||
|
</p>
|
||||||
|
<div class="modal-actions restore-actions-left">
|
||||||
|
<button class="modal-btn" :disabled="step === 'checking'" @click="fileInput?.click()">
|
||||||
|
Choose a file…
|
||||||
|
</button>
|
||||||
|
<input ref="fileInput" type="file" accept=".json,.tar" class="hidden-file-input" @change="pickFile" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="step === 'checking'" class="restore-muted">Reading {{ chosenLabel }}…</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="modal-btn" @click="emit('close')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="(step === 'preview' || step === 'running') && preview">
|
||||||
|
<h3 class="modal-title">
|
||||||
|
{{ preview.mode === "rollback" ? "Roll back to this backup?" : "Restore this backup?" }}
|
||||||
|
</h3>
|
||||||
|
<p class="modal-message">
|
||||||
|
<template v-if="preview.mode === 'rollback'">
|
||||||
|
Everything goes back to {{ when(preview.exported_at) }}, for every user. Anything made
|
||||||
|
since is removed, edits since are undone, and records deleted since come back.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
This instance has no records yet, so it takes the backup from {{ when(preview.exported_at) }} as it is.
|
||||||
|
</template>
|
||||||
|
The current state is backed up first, and everyone is signed out afterwards.
|
||||||
|
</p>
|
||||||
|
<table class="restore-counts">
|
||||||
|
<thead><tr><th></th><th>Now</th><th>After</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="c in preview.counts" :key="c.kind">
|
||||||
|
<td>{{ c.kind }}</td><td>{{ c.now }}</td><td>{{ c.after }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-if="error" class="restore-error" role="alert">{{ error }}</p>
|
||||||
|
<label class="restore-confirm">
|
||||||
|
Type <strong>{{ CONFIRM_WORDS }}</strong> to continue
|
||||||
|
<input v-model="typed" class="fs-input input" :disabled="step === 'running'" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="modal-btn" :disabled="step === 'running'" @click="step = 'choose'">Back</button>
|
||||||
|
<button
|
||||||
|
class="modal-btn modal-btn-danger"
|
||||||
|
:disabled="!confirmed || step === 'running'"
|
||||||
|
@click="run"
|
||||||
|
>{{ step === "running" ? "Restoring…" : preview.mode === "rollback" ? "Roll back" : "Restore" }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="step === 'done' && result">
|
||||||
|
<h3 class="modal-title">Restored</h3>
|
||||||
|
<p class="modal-message">
|
||||||
|
The instance is back to {{ when(result.exported_at) }}.
|
||||||
|
The state it replaced was saved as <code>{{ result.safety_backup.file }}</code>
|
||||||
|
in {{ result.safety_backup.where }}.
|
||||||
|
<template v-if="result.safety_backup.note"> {{ result.safety_backup.note }}</template>
|
||||||
|
Search is being rebuilt in the background. Everyone has been signed out, so sign in again.
|
||||||
|
</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="modal-btn modal-btn-primary" @click="finish">Sign in again</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.restore-card {
|
||||||
|
max-width: 34rem;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.restore-subhead {
|
||||||
|
margin: 1rem 0 0.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.restore-muted {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.restore-error {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--fs-action-destructive);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.restore-sources {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
}
|
||||||
|
.restore-source {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
|
background: none;
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.restore-sources li:last-child .restore-source {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.restore-source:hover:not(:disabled) {
|
||||||
|
background: var(--fs-surface-hover);
|
||||||
|
}
|
||||||
|
.restore-actions-left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.restore-counts {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.restore-counts th,
|
||||||
|
.restore-counts td {
|
||||||
|
text-align: right;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
|
}
|
||||||
|
.restore-counts th:first-child,
|
||||||
|
.restore-counts td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.hidden-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.restore-confirm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { apiGet, apiPut, apiErrorMessage } from "@/api/client";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many reverse proxies stand in front of Grimoire, checked against the
|
||||||
|
* admin's own request.
|
||||||
|
*
|
||||||
|
* Each proxy appends to X-Forwarded-For, and Grimoire reads the entry this many
|
||||||
|
* in from the right as the visitor's address. Every sign-in rate limit and
|
||||||
|
* audit line keys on that address, and the scheme read the same way decides
|
||||||
|
* whether the session cookie is Secure. Too low a count makes every visitor
|
||||||
|
* read as the proxy, so they share one rate limit and one bad sign-in run
|
||||||
|
* blocks everyone. Behind Cloudflare's proxied DNS the usual count is 2.
|
||||||
|
*/
|
||||||
|
interface ReadoutRow { hops: number; client: string; https: boolean }
|
||||||
|
interface Readout {
|
||||||
|
peer: string;
|
||||||
|
forwarded_for: string[];
|
||||||
|
forwarded_proto: string[];
|
||||||
|
cloudflare: boolean;
|
||||||
|
cloudflare_client: string | null;
|
||||||
|
rows: ReadoutRow[];
|
||||||
|
suggested: number | null;
|
||||||
|
}
|
||||||
|
interface ProxyConfig {
|
||||||
|
hops: number;
|
||||||
|
source: "setting" | "env" | "default";
|
||||||
|
env_hops: number;
|
||||||
|
max_hops: number;
|
||||||
|
readout: Readout;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = useToastStore();
|
||||||
|
const config = ref<ProxyConfig | null>(null);
|
||||||
|
const loadError = ref("");
|
||||||
|
const draft = ref<number>(1);
|
||||||
|
const saving = ref(false);
|
||||||
|
const saved = ref(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loadError.value = "";
|
||||||
|
try {
|
||||||
|
config.value = await apiGet<ProxyConfig>("/api/admin/proxy");
|
||||||
|
draft.value = config.value.hops;
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = apiErrorMessage(e, "The proxy settings could not be loaded.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(hops: number | null) {
|
||||||
|
saving.value = true;
|
||||||
|
saved.value = false;
|
||||||
|
try {
|
||||||
|
config.value = await apiPut<ProxyConfig>("/api/admin/proxy", { hops });
|
||||||
|
draft.value = config.value.hops;
|
||||||
|
saved.value = true;
|
||||||
|
setTimeout(() => (saved.value = false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(apiErrorMessage(e, "Failed to save the proxy count"), "error");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readout = computed(() => config.value?.readout ?? null);
|
||||||
|
const inForce = computed(() => readout.value?.rows.find((r) => r.hops === config.value?.hops) ?? null);
|
||||||
|
|
||||||
|
// The verdict that leads the section. A Cloudflare request names the count to
|
||||||
|
// set, since Cloudflare reports the visitor's address; any other road can only
|
||||||
|
// show the address read and let the admin judge whether it is theirs.
|
||||||
|
const verdict = computed<"set-suggested" | "cloudflare-unmatched" | "check" | null>(() => {
|
||||||
|
const r = readout.value;
|
||||||
|
if (!r || !config.value) return null;
|
||||||
|
if (r.cloudflare && r.suggested !== null && r.suggested !== config.value.hops) return "set-suggested";
|
||||||
|
if (r.cloudflare && r.suggested === null) return "cloudflare-unmatched";
|
||||||
|
return "check";
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceLabel = computed(() => {
|
||||||
|
const c = config.value;
|
||||||
|
if (!c) return "";
|
||||||
|
if (c.source === "setting") return "Saved here.";
|
||||||
|
if (c.source === "env") return `From TRUSTED_PROXY_HOPS in the environment (${c.env_hops}).`;
|
||||||
|
return "The default.";
|
||||||
|
});
|
||||||
|
|
||||||
|
const draftValid = computed(() =>
|
||||||
|
Number.isInteger(draft.value) && draft.value >= 0 && draft.value <= (config.value?.max_hops ?? 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="proxy-settings">
|
||||||
|
<p v-if="loadError" class="error-msg">
|
||||||
|
{{ loadError }}
|
||||||
|
<button type="button" class="btn-ghost btn-compact" @click="load">Try again</button>
|
||||||
|
</p>
|
||||||
|
<p v-else-if="!config" class="state-msg">Reading this request…</p>
|
||||||
|
|
||||||
|
<template v-else-if="readout">
|
||||||
|
<div v-if="verdict === 'set-suggested'" class="proxy-warning" role="alert">
|
||||||
|
<p class="proxy-warning-title">Set the proxy count to {{ readout.suggested }}</p>
|
||||||
|
<p class="proxy-warning-body">
|
||||||
|
This request came through Cloudflare, which reports your address as
|
||||||
|
<code>{{ readout.cloudflare_client }}</code>. At {{ config.hops }}, Grimoire reads
|
||||||
|
<code>{{ inForce?.client }}</code> instead, so visitors share Cloudflare's addresses
|
||||||
|
and one sign-in limit.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn-primary" :disabled="saving" @click="save(readout.suggested)">
|
||||||
|
Use {{ readout.suggested }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="verdict === 'cloudflare-unmatched'" class="proxy-warning" role="alert">
|
||||||
|
<p class="proxy-warning-title">This request came through Cloudflare</p>
|
||||||
|
<p class="proxy-warning-body">
|
||||||
|
No count reads the address Cloudflare reports for you. Check the forwarding chain below,
|
||||||
|
and that your own proxy passes Cloudflare's <code>X-Forwarded-For</code> on.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-else class="proxy-verdict">
|
||||||
|
Grimoire reads this request as coming from <code>{{ inForce?.client || "an unknown address" }}</code>
|
||||||
|
<template v-if="inForce?.https"> over HTTPS</template>. If that is your address, the count
|
||||||
|
is right. If it is your proxy's, raise the count.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="field proxy-field">
|
||||||
|
<label for="proxy-hops">Proxies in front of Grimoire</label>
|
||||||
|
<div class="proxy-input-row">
|
||||||
|
<input
|
||||||
|
id="proxy-hops"
|
||||||
|
v-model.number="draft"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
:max="config.max_hops"
|
||||||
|
step="1"
|
||||||
|
class="fs-input input proxy-hops-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="saving || !draftValid || draft === config.hops"
|
||||||
|
@click="save(draft)"
|
||||||
|
>
|
||||||
|
{{ saving ? "Saving..." : "Save" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="config.source === 'setting'"
|
||||||
|
type="button"
|
||||||
|
class="btn-ghost"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="save(null)"
|
||||||
|
>
|
||||||
|
Use the environment's ({{ config.env_hops }})
|
||||||
|
</button>
|
||||||
|
<span v-if="saved" class="saved-msg">Saved. It applies to the next request.</span>
|
||||||
|
</div>
|
||||||
|
<p class="field-hint">
|
||||||
|
{{ sourceLabel }} One proxy (Traefik, Nginx, Caddy) is 1. Cloudflare in front of your own
|
||||||
|
proxy is 2. 0 ignores the forwarding headers and uses the connecting address.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="proxy-table">
|
||||||
|
<caption>What each count makes of this request</caption>
|
||||||
|
<thead>
|
||||||
|
<tr><th class="num">Count</th><th>Reads you as</th><th>HTTPS</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in readout.rows"
|
||||||
|
:key="row.hops"
|
||||||
|
:class="{ 'proxy-row-current': row.hops === config.hops }"
|
||||||
|
>
|
||||||
|
<td class="num">{{ row.hops }}</td>
|
||||||
|
<td><code>{{ row.client || "unknown" }}</code></td>
|
||||||
|
<td>{{ row.https ? "yes" : "no" }}</td>
|
||||||
|
<td class="proxy-row-tags">
|
||||||
|
<span v-if="row.hops === config.hops" class="proxy-tag">in force</span>
|
||||||
|
<span v-if="row.hops === readout.suggested && row.hops !== config.hops" class="proxy-tag proxy-tag-suggested">
|
||||||
|
suggested
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<dl class="proxy-chain">
|
||||||
|
<dt>Connecting address</dt>
|
||||||
|
<dd><code>{{ readout.peer || "unknown" }}</code></dd>
|
||||||
|
<dt>X-Forwarded-For</dt>
|
||||||
|
<dd v-if="readout.forwarded_for.length"><code>{{ readout.forwarded_for.join(", ") }}</code></dd>
|
||||||
|
<dd v-else class="proxy-absent">not sent</dd>
|
||||||
|
<dt>X-Forwarded-Proto</dt>
|
||||||
|
<dd v-if="readout.forwarded_proto.length"><code>{{ readout.forwarded_proto.join(", ") }}</code></dd>
|
||||||
|
<dd v-else class="proxy-absent">not sent</dd>
|
||||||
|
</dl>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.proxy-warning {
|
||||||
|
margin-bottom: var(--fs-space-4);
|
||||||
|
padding: var(--fs-space-3);
|
||||||
|
border: 1px solid var(--fs-warning);
|
||||||
|
border-radius: var(--fs-radius-md);
|
||||||
|
background: color-mix(in srgb, var(--fs-warning) 12%, var(--fs-surface-raised));
|
||||||
|
}
|
||||||
|
.proxy-warning-title {
|
||||||
|
margin: 0 0 var(--fs-space-2);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fs-warning-fg);
|
||||||
|
}
|
||||||
|
.proxy-warning-body {
|
||||||
|
margin: 0 0 var(--fs-space-3);
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
}
|
||||||
|
.proxy-verdict {
|
||||||
|
margin: 0 0 var(--fs-space-4);
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
}
|
||||||
|
.proxy-field {
|
||||||
|
margin-bottom: var(--fs-space-4);
|
||||||
|
}
|
||||||
|
.proxy-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--fs-space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.proxy-hops-input {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
.proxy-table {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
margin-bottom: var(--fs-space-4);
|
||||||
|
}
|
||||||
|
.proxy-table caption {
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--fs-size-label);
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
margin-bottom: var(--fs-space-2);
|
||||||
|
}
|
||||||
|
.proxy-table th,
|
||||||
|
.proxy-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--fs-space-1) var(--fs-space-3);
|
||||||
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
|
}
|
||||||
|
.proxy-table th {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.proxy-table .num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
width: 4rem;
|
||||||
|
}
|
||||||
|
.proxy-row-current td {
|
||||||
|
background: var(--fs-surface-hover);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.proxy-row-tags {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.proxy-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 var(--fs-space-2);
|
||||||
|
border-radius: var(--fs-radius-pill);
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
border: 1px solid var(--fs-border-active);
|
||||||
|
}
|
||||||
|
.proxy-tag-suggested {
|
||||||
|
color: var(--fs-warning-fg);
|
||||||
|
border-color: var(--fs-warning);
|
||||||
|
}
|
||||||
|
.proxy-chain {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: var(--fs-space-1) var(--fs-space-4);
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
}
|
||||||
|
.proxy-chain dt {
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
}
|
||||||
|
.proxy-chain dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.proxy-absent {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* The end of a server-paged list: asks for the next page as the reader nears
|
||||||
|
* it, in place of a "Load more" button. The parent keeps the paging — what the
|
||||||
|
* next page is keyed on, whether one is in flight, whether the last one
|
||||||
|
* failed — and this owns the observing.
|
||||||
|
*
|
||||||
|
* <ScrollSentinel :cursor="more ? String(rows.length) : ''"
|
||||||
|
* :loading="loadingMore" :failed="moreFailed" @more="loadMore" />
|
||||||
|
*
|
||||||
|
* An empty cursor means the list is complete, and nothing renders.
|
||||||
|
*/
|
||||||
|
import { onBeforeUnmount, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ cursor: string; loading: boolean; failed?: boolean }>();
|
||||||
|
const emit = defineEmits<{ more: [] }>();
|
||||||
|
|
||||||
|
const sentinel = ref<HTMLElement | null>(null);
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
|
// One request per page. The observer reports every crossing, and a short last
|
||||||
|
// page leaves the sentinel on screen for good, so the cursor last asked for is
|
||||||
|
// remembered: a page is asked for once, and a failed one is not retried on
|
||||||
|
// every scroll, because a failure leaves the cursor where it was. Only
|
||||||
|
// Try again asks for it again.
|
||||||
|
let asked = "";
|
||||||
|
|
||||||
|
function pump() {
|
||||||
|
if (!visible.value || props.loading || props.failed || !props.cursor) return;
|
||||||
|
if (props.cursor === asked) return;
|
||||||
|
asked = props.cursor;
|
||||||
|
emit("more");
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
asked = props.cursor;
|
||||||
|
emit("more");
|
||||||
|
}
|
||||||
|
|
||||||
|
let observer: IntersectionObserver | undefined;
|
||||||
|
|
||||||
|
function observe(el: HTMLElement | null) {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = undefined;
|
||||||
|
if (!el) {
|
||||||
|
// Visibility and the cursor asked for go with the element. A list that
|
||||||
|
// starts over must not inherit "the end is on screen" from the one it
|
||||||
|
// replaced, and an offset cursor repeats from list to list, so "50" asked
|
||||||
|
// of the old list must not stop the new one asking for its own "50".
|
||||||
|
visible.value = false;
|
||||||
|
asked = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
visible.value = entries.some((e) => e.isIntersecting);
|
||||||
|
pump();
|
||||||
|
},
|
||||||
|
// Extends the watched area a screen below the viewport, so the next page is
|
||||||
|
// asked for before the reader reaches the end rather than when they do.
|
||||||
|
{ rootMargin: "0px 0px 100% 0px" },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush: "post": the template ref is only set once the DOM is patched.
|
||||||
|
watch(sentinel, observe, { flush: "post" });
|
||||||
|
|
||||||
|
// A page that lands without pushing the end off screen gives the observer
|
||||||
|
// nothing new to report, so carry on from the props, or the list stops early.
|
||||||
|
watch([() => props.cursor, () => props.loading, () => props.failed], pump);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => observer?.disconnect());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="cursor" class="sentinel">
|
||||||
|
<!-- The one case with a button: an observer re-firing on a failure would be
|
||||||
|
a request loop. -->
|
||||||
|
<p v-if="failed" class="sentinel-note sentinel-failed" role="alert">
|
||||||
|
Couldn’t load more.
|
||||||
|
<button type="button" class="btn-secondary btn-sm" @click="retry">Try again</button>
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<div ref="sentinel" class="sentinel-mark" aria-hidden="true"></div>
|
||||||
|
<p v-if="loading" class="sentinel-note" role="status">Loading more…</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sentinel { grid-column: 1 / -1; padding: 0.75rem 0; text-align: center; }
|
||||||
|
.sentinel-mark { height: 1px; }
|
||||||
|
.sentinel-note { margin: 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||||
|
.sentinel-failed { display: flex; align-items: center; justify-content: center; gap: 0.75rem; }
|
||||||
|
</style>
|
||||||
@@ -1,683 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
|
||||||
import type { Editor } from "@tiptap/vue-3";
|
|
||||||
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
|
||||||
import { useToastStore } from "@/stores/toast";
|
|
||||||
import { useAutoSave } from "@/composables/useAutoSave";
|
|
||||||
import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
|
||||||
import { useNotesStore } from "@/stores/notes";
|
|
||||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
|
||||||
import TagInput from "@/components/TagInput.vue";
|
|
||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
|
||||||
import WordCount from "@/components/WordCount.vue";
|
|
||||||
import { Trash2, X } from "lucide-vue-next";
|
|
||||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
projectId: number;
|
|
||||||
activeNoteId: number | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{ "note-loaded": [id: number] }>();
|
|
||||||
|
|
||||||
interface NoteItem {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
tags: string[];
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toast = useToastStore();
|
|
||||||
const notesStore = useNotesStore();
|
|
||||||
|
|
||||||
// List state
|
|
||||||
const projectNotes = ref<NoteItem[]>([]);
|
|
||||||
const listLoading = ref(false);
|
|
||||||
const deletingId = ref<number | null>(null);
|
|
||||||
const pendingDelete = ref<number | null>(null);
|
|
||||||
const searchQuery = ref("");
|
|
||||||
|
|
||||||
const filteredNotes = computed(() => {
|
|
||||||
const q = searchQuery.value.trim().toLowerCase();
|
|
||||||
if (!q) return projectNotes.value;
|
|
||||||
return projectNotes.value.filter(
|
|
||||||
(n) => n.title.toLowerCase().includes(q) || n.tags?.some((t) => t.toLowerCase().includes(q))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function matchedTags(note: NoteItem): string[] {
|
|
||||||
const q = searchQuery.value.trim().toLowerCase();
|
|
||||||
if (!q) return [];
|
|
||||||
return (note.tags ?? []).filter(
|
|
||||||
(t) => t.toLowerCase().includes(q) && !note.title.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Editor state
|
|
||||||
const editingId = ref<number | null>(null);
|
|
||||||
const noteTitle = ref("");
|
|
||||||
const noteBody = ref("");
|
|
||||||
const noteTags = ref<string[]>([]);
|
|
||||||
const dirty = ref(false);
|
|
||||||
const saving = ref(false);
|
|
||||||
|
|
||||||
const tagSuggestions = useTagSuggestions(noteTitle, noteBody, noteTags, () => { dirty.value = true; });
|
|
||||||
|
|
||||||
// New note inline creation
|
|
||||||
const showNewNoteInput = ref(false);
|
|
||||||
const newNoteTitle = ref("");
|
|
||||||
const creatingNote = ref(false);
|
|
||||||
const newNoteTitleRef = ref<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
function _noteKey(pid: number) { return `workspace_note_${pid}`; }
|
|
||||||
|
|
||||||
async function startNewNote() {
|
|
||||||
showNewNoteInput.value = true;
|
|
||||||
newNoteTitle.value = "";
|
|
||||||
await nextTick();
|
|
||||||
newNoteTitleRef.value?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelNewNote() {
|
|
||||||
showNewNoteInput.value = false;
|
|
||||||
newNoteTitle.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createNote() {
|
|
||||||
const title = newNoteTitle.value.trim();
|
|
||||||
if (!title || creatingNote.value) return;
|
|
||||||
creatingNote.value = true;
|
|
||||||
try {
|
|
||||||
const note = await apiPost<{ id: number; title: string; body: string; tags: string[]; updated_at: string }>(
|
|
||||||
"/api/notes",
|
|
||||||
{ title, project_id: props.projectId, body: "", is_task: false }
|
|
||||||
);
|
|
||||||
showNewNoteInput.value = false;
|
|
||||||
newNoteTitle.value = "";
|
|
||||||
await loadProjectNotes();
|
|
||||||
await openNote(note.id);
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to create note", "error");
|
|
||||||
} finally {
|
|
||||||
creatingNote.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TipTap editor ref
|
|
||||||
const titleRef = ref<HTMLInputElement | null>(null);
|
|
||||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
|
||||||
const tiptapEditor = computed<Editor | null>(() =>
|
|
||||||
(editorRef.value?.editor as Editor | undefined) ?? null
|
|
||||||
);
|
|
||||||
|
|
||||||
// Link suggestions
|
|
||||||
interface LinkSuggestion { note_id: number; title: string; count: number; }
|
|
||||||
const linkSuggestions = ref<LinkSuggestion[]>([]);
|
|
||||||
let linkCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
async function fetchLinkSuggestions() {
|
|
||||||
if (!editingId.value || !noteBody.value) { linkSuggestions.value = []; return; }
|
|
||||||
try {
|
|
||||||
const res = await apiPost<{ suggestions: LinkSuggestion[] }>('/api/notes/link-suggestions', {
|
|
||||||
body: noteBody.value,
|
|
||||||
project_id: props.projectId,
|
|
||||||
exclude_note_id: editingId.value,
|
|
||||||
});
|
|
||||||
linkSuggestions.value = res.suggestions;
|
|
||||||
} catch {
|
|
||||||
linkSuggestions.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleLinkCheck() {
|
|
||||||
if (linkCheckTimer) clearTimeout(linkCheckTimer);
|
|
||||||
linkCheckTimer = setTimeout(fetchLinkSuggestions, 2500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyWikilinkToText(text: string, title: string): string {
|
|
||||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
const parts = text.split(/(\[\[[^\]]*\]\])/);
|
|
||||||
const pattern = new RegExp(`\\b${escaped}\\b`, 'gi');
|
|
||||||
return parts.map((part, i) =>
|
|
||||||
i % 2 === 0 ? part.replace(pattern, `[[${title}]]`) : part
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyLink(s: LinkSuggestion) {
|
|
||||||
noteBody.value = applyWikilinkToText(noteBody.value, s.title);
|
|
||||||
dirty.value = true;
|
|
||||||
linkSuggestions.value = linkSuggestions.value.filter(x => x.note_id !== s.note_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAllLinks() {
|
|
||||||
let text = noteBody.value;
|
|
||||||
for (const s of linkSuggestions.value) text = applyWikilinkToText(text, s.title);
|
|
||||||
noteBody.value = text;
|
|
||||||
dirty.value = true;
|
|
||||||
linkSuggestions.value = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProjectNotes() {
|
|
||||||
listLoading.value = true;
|
|
||||||
try {
|
|
||||||
const data = await apiGet<{ notes: NoteItem[] }>(
|
|
||||||
`/api/projects/${props.projectId}/notes?type=note&limit=200`
|
|
||||||
);
|
|
||||||
projectNotes.value = data.notes ?? [];
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to load notes", "error");
|
|
||||||
} finally {
|
|
||||||
listLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openNote(id: number) {
|
|
||||||
try {
|
|
||||||
const note = await apiGet<{ id: number; title: string; body: string; tags: string[]; updated_at: string }>(
|
|
||||||
`/api/notes/${id}`
|
|
||||||
);
|
|
||||||
editingId.value = note.id;
|
|
||||||
noteTitle.value = note.title;
|
|
||||||
noteBody.value = note.body ?? "";
|
|
||||||
noteTags.value = note.tags ?? [];
|
|
||||||
dirty.value = false;
|
|
||||||
tagSuggestions.dismissTagSuggestions();
|
|
||||||
linkSuggestions.value = [];
|
|
||||||
localStorage.setItem(_noteKey(props.projectId), String(id));
|
|
||||||
fetchLinkSuggestions();
|
|
||||||
emit("note-loaded", note.id);
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to load note", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveNote() {
|
|
||||||
if (!editingId.value) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
await apiPatch(`/api/notes/${editingId.value}`, {
|
|
||||||
title: noteTitle.value,
|
|
||||||
body: noteBody.value,
|
|
||||||
tags: noteTags.value,
|
|
||||||
});
|
|
||||||
dirty.value = false;
|
|
||||||
const idx = projectNotes.value.findIndex((n) => n.id === editingId.value);
|
|
||||||
if (idx !== -1) {
|
|
||||||
projectNotes.value[idx] = {
|
|
||||||
...projectNotes.value[idx],
|
|
||||||
title: noteTitle.value,
|
|
||||||
tags: noteTags.value,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
projectNotes.value.sort((a, b) =>
|
|
||||||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to save note", "error");
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestDelete(id: number, e: Event) {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (deletingId.value === id) {
|
|
||||||
confirmDelete(id);
|
|
||||||
} else {
|
|
||||||
deletingId.value = id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDelete(e: Event) {
|
|
||||||
e.stopPropagation();
|
|
||||||
deletingId.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmDelete(id: number) {
|
|
||||||
pendingDelete.value = id;
|
|
||||||
try {
|
|
||||||
await apiDelete(`/api/notes/${id}`);
|
|
||||||
projectNotes.value = projectNotes.value.filter((n) => n.id !== id);
|
|
||||||
if (editingId.value === id) {
|
|
||||||
editingId.value = null;
|
|
||||||
noteTitle.value = "";
|
|
||||||
noteBody.value = "";
|
|
||||||
localStorage.removeItem(_noteKey(props.projectId));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to delete note", "error");
|
|
||||||
} finally {
|
|
||||||
pendingDelete.value = null;
|
|
||||||
deletingId.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(noteTitle, () => { dirty.value = true; });
|
|
||||||
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
|
|
||||||
watch(noteTags, () => { dirty.value = true; });
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.activeNoteId,
|
|
||||||
async (id) => {
|
|
||||||
if (id) {
|
|
||||||
await openNote(id);
|
|
||||||
await loadProjectNotes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useAutoSave(dirty, saving, saveNote, 60_000);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadProjectNotes();
|
|
||||||
const stored = localStorage.getItem(_noteKey(props.projectId));
|
|
||||||
if (stored) {
|
|
||||||
const id = Number(stored);
|
|
||||||
if (projectNotes.value.find((n) => n.id === id)) {
|
|
||||||
openNote(id).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
|
|
||||||
|
|
||||||
defineExpose({ reload: loadProjectNotes });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="ws-note-editor">
|
|
||||||
|
|
||||||
<!-- Left rail: always-visible note list -->
|
|
||||||
<div class="note-rail">
|
|
||||||
<div class="rail-header">
|
|
||||||
<template v-if="showNewNoteInput">
|
|
||||||
<input
|
|
||||||
ref="newNoteTitleRef"
|
|
||||||
v-model="newNoteTitle"
|
|
||||||
class="new-note-input"
|
|
||||||
placeholder="Note title..."
|
|
||||||
:disabled="creatingNote"
|
|
||||||
@keydown.enter="createNote"
|
|
||||||
@keydown.escape="cancelNewNote"
|
|
||||||
/>
|
|
||||||
<button class="btn-primary btn-inline" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
|
||||||
{{ creatingNote ? '…' : '+' }}
|
|
||||||
</button>
|
|
||||||
<button class="btn-text" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="rail-title">Notes</span>
|
|
||||||
<button class="btn-ghost btn-inline" @click="startNewNote" title="New note">+ New</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rail-search">
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
class="rail-search-input"
|
|
||||||
placeholder="Search…"
|
|
||||||
type="search"
|
|
||||||
aria-label="Search notes"
|
|
||||||
/>
|
|
||||||
<button v-if="searchQuery" class="btn-text btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="listLoading" class="rail-state">Loading…</div>
|
|
||||||
<ul v-else class="note-list">
|
|
||||||
<li
|
|
||||||
v-for="note in filteredNotes"
|
|
||||||
:key="note.id"
|
|
||||||
:class="['note-row', { active: editingId === note.id }]"
|
|
||||||
@click="openNote(note.id)"
|
|
||||||
>
|
|
||||||
<div class="note-row-main">
|
|
||||||
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
|
|
||||||
<span class="note-row-age">{{ relativeTimeOrDate(note.updated_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="note.tags?.length" class="note-row-tags">
|
|
||||||
<span
|
|
||||||
v-for="tag in note.tags.slice(0, 2)"
|
|
||||||
:key="tag"
|
|
||||||
:class="['note-tag-pill', { 'tag-match': matchedTags(note).includes(tag) }]"
|
|
||||||
>#{{ tag }}</span>
|
|
||||||
<span v-if="note.tags.length > 2" class="note-tag-more">+{{ note.tags.length - 2 }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="note-row-actions" @click.stop>
|
|
||||||
<template v-if="deletingId === note.id">
|
|
||||||
<button class="btn-danger-outline btn-inline" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
|
|
||||||
{{ pendingDelete === note.id ? '…' : 'Delete?' }}
|
|
||||||
</button>
|
|
||||||
<button class="btn-text" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
|
|
||||||
</template>
|
|
||||||
<button v-else class="btn-text" title="Delete note" @click="requestDelete(note.id, $event)">
|
|
||||||
<Trash2 :size="16" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li v-if="filteredNotes.length === 0" class="rail-state">
|
|
||||||
{{ searchQuery.trim() ? 'No matches.' : 'No notes yet.' }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right pane: editor -->
|
|
||||||
<div class="note-editor-pane">
|
|
||||||
<template v-if="editingId">
|
|
||||||
<div class="panel-header">
|
|
||||||
<div class="editor-header-right">
|
|
||||||
<WordCount :body="noteBody" />
|
|
||||||
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
|
|
||||||
<span v-if="saving" class="saving-txt">Saving…</span>
|
|
||||||
<button class="btn-primary btn-compact" :disabled="saving || !dirty" @click="saveNote">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="note-title-row">
|
|
||||||
<input
|
|
||||||
ref="titleRef"
|
|
||||||
v-model="noteTitle"
|
|
||||||
class="note-title-input"
|
|
||||||
placeholder="Note title"
|
|
||||||
@keydown.ctrl.s.prevent="saveNote"
|
|
||||||
@keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tag-row">
|
|
||||||
<TagInput v-model="noteTags" :fetchTags="(q: string) => notesStore.fetchAllTags(q)" />
|
|
||||||
<button
|
|
||||||
class="btn-ghost btn-compact"
|
|
||||||
:disabled="tagSuggestions.suggestingTags.value"
|
|
||||||
title="Auto-suggest tags from title and body"
|
|
||||||
@click="tagSuggestions.fetchTagSuggestions()"
|
|
||||||
>
|
|
||||||
{{ tagSuggestions.suggestingTags.value ? '…' : 'Suggest' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="tagSuggestions.suggestedTags.value.length" class="tag-suggestions">
|
|
||||||
<span class="tag-suggestions-label">Suggested:</span>
|
|
||||||
<button
|
|
||||||
v-for="tag in tagSuggestions.suggestedTags.value"
|
|
||||||
:key="tag"
|
|
||||||
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
|
|
||||||
@click="tagSuggestions.applyTagSuggestion(tag)"
|
|
||||||
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
|
|
||||||
<button class="btn-text" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toolbar-row">
|
|
||||||
<MarkdownToolbar :editor="tiptapEditor" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="linkSuggestions.length" class="link-suggest-strip">
|
|
||||||
<span class="link-suggest-label">Links:</span>
|
|
||||||
<span v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-chip" :title="`Appears ${s.count}× unlinked`">
|
|
||||||
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
|
||||||
</span>
|
|
||||||
<button class="btn-ghost btn-inline" @click="applyAllLinks" title="Link all suggestions">All</button>
|
|
||||||
<button class="btn-text" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
|
||||||
<TiptapEditor ref="editorRef" v-model="noteBody" @escape="titleRef?.focus()" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-else class="editor-empty-state">
|
|
||||||
<p>Select a note or create a new one</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ws-note-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--fs-surface-hover);
|
|
||||||
border-left: 1px solid var(--fs-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Left rail ── */
|
|
||||||
.note-rail {
|
|
||||||
width: 200px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--fs-surface-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-title {
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 500;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-search-input {
|
|
||||||
flex: 1;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--fs-text-primary);
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.rail-search-input:focus { outline: none; }
|
|
||||||
.rail-search-input::-webkit-search-cancel-button { display: none; }
|
|
||||||
|
|
||||||
/* Sits inside the search field: no padding, and must not flex. */
|
|
||||||
.btn-search-clear { padding: 0; flex-shrink: 0; }
|
|
||||||
|
|
||||||
.rail-state {
|
|
||||||
padding: 1rem 0.65rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Note list */
|
|
||||||
.note-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 60%, transparent);
|
|
||||||
cursor: pointer;
|
|
||||||
gap: 0.15rem;
|
|
||||||
border-right: 2px solid transparent;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
.note-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
|
|
||||||
.note-row.active {
|
|
||||||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover));
|
|
||||||
border-right-color: var(--fs-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-row-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-row-title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 500;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--fs-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-row-age {
|
|
||||||
font-size: 0.62rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-row-tags {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.15rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-tag-pill {
|
|
||||||
font-size: 0.58rem;
|
|
||||||
color: var(--fs-accent-fg);
|
|
||||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0 0.3rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 5rem;
|
|
||||||
}
|
|
||||||
.note-tag-pill.tag-match {
|
|
||||||
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
|
|
||||||
outline: 1px solid var(--fs-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-tag-more {
|
|
||||||
font-size: 0.58rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-row-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.2rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Editor UI */
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unsaved { font-size: 0.72rem; color: var(--fs-text-tertiary); }
|
|
||||||
.saving-txt { font-size: 0.72rem; color: var(--fs-accent); }
|
|
||||||
|
|
||||||
/* Moss action-primary per Hybrid */
|
|
||||||
|
|
||||||
.note-title-input {
|
|
||||||
width: 100%;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.25;
|
|
||||||
color: var(--fs-text-primary);
|
|
||||||
padding: 0;
|
|
||||||
font-family: 'Fraunces', serif;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
.note-title-input:focus { outline: none; }
|
|
||||||
.note-title-input::placeholder {
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
|
||||||
|
|
||||||
.tag-suggestions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 0.35rem 0.6rem;
|
|
||||||
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.tag-suggestions-label { font-size: 0.72rem; color: var(--fs-text-tertiary); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.btn-tag-suggestion {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.15rem 0.55rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--fs-text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-tag-suggestion:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
|
||||||
.btn-tag-suggestion.applied {
|
|
||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
|
||||||
border-color: var(--fs-accent);
|
|
||||||
color: var(--fs-accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-suggest-strip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.link-suggest-label {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.link-suggest-chip { display: inline-flex; }
|
|
||||||
|
|
||||||
.btn-chip-link {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--fs-accent);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.1rem 0.45rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--fs-accent);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: monospace;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.btn-chip-link:hover { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@@ -1,618 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from "vue";
|
|
||||||
import { RouterLink } from "vue-router";
|
|
||||||
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
|
||||||
import { useToastStore } from "@/stores/toast";
|
|
||||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
|
||||||
import KindBadge from "@/components/KindBadge.vue";
|
|
||||||
import type { TaskKind } from "@/types/note";
|
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
|
||||||
import { Trash2, X } from "lucide-vue-next";
|
|
||||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
|
||||||
|
|
||||||
const props = defineProps<{ projectId: number }>();
|
|
||||||
|
|
||||||
const toast = useToastStore();
|
|
||||||
|
|
||||||
interface Milestone {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
order_index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Task {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
priority: string;
|
|
||||||
milestone_id: number | null;
|
|
||||||
due_date: string | null;
|
|
||||||
updated_at: string;
|
|
||||||
body?: string;
|
|
||||||
task_kind?: TaskKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tasks = ref<Task[]>([]);
|
|
||||||
const milestones = ref<Milestone[]>([]);
|
|
||||||
const loading = ref(false);
|
|
||||||
|
|
||||||
// Detail slide-over
|
|
||||||
const activeTask = ref<Task | null>(null);
|
|
||||||
const taskBody = ref("");
|
|
||||||
const loadingBody = ref(false);
|
|
||||||
|
|
||||||
// Collapsed milestone groups (set of milestone IDs, null = "No Milestone" group)
|
|
||||||
const collapsedGroups = ref<Set<number | null>>(new Set());
|
|
||||||
|
|
||||||
// New task quick-add
|
|
||||||
const newTaskTitle = ref("");
|
|
||||||
const addingTask = ref(false);
|
|
||||||
|
|
||||||
const STATUS_CYCLE: Record<string, string> = {
|
|
||||||
todo: "in_progress",
|
|
||||||
in_progress: "done",
|
|
||||||
done: "todo",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_ICON: Record<string, string> = {
|
|
||||||
todo: "○",
|
|
||||||
in_progress: "▶",
|
|
||||||
done: "✓",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRIORITY_CLASS: Record<string, string> = {
|
|
||||||
high: "priority-high",
|
|
||||||
medium: "priority-medium",
|
|
||||||
low: "priority-low",
|
|
||||||
};
|
|
||||||
|
|
||||||
function isRowOverdue(task: Task): boolean {
|
|
||||||
if (!task.due_date || task.status === "done") return false;
|
|
||||||
return task.due_date < new Date().toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group tasks by milestone, "No Milestone" first
|
|
||||||
const groupedTasks = computed(() => {
|
|
||||||
const noMs = tasks.value.filter((t) => t.milestone_id === null);
|
|
||||||
const msGroups = milestones.value.map((ms) => ({
|
|
||||||
milestone: ms,
|
|
||||||
tasks: tasks.value.filter((t) => t.milestone_id === ms.id),
|
|
||||||
}));
|
|
||||||
return { noMilestone: noMs, milestoneGroups: msGroups };
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadAll() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const [tasksRes, msRes] = await Promise.all([
|
|
||||||
apiGet<{ notes: Task[] }>(`/api/projects/${props.projectId}/notes?type=task&limit=200`),
|
|
||||||
apiGet<{ milestones: Milestone[] }>(`/api/projects/${props.projectId}/milestones`),
|
|
||||||
]);
|
|
||||||
tasks.value = tasksRes.notes ?? [];
|
|
||||||
milestones.value = msRes.milestones ?? [];
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to load tasks", "error");
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cycleStatus(task: Task, e: Event) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const next = STATUS_CYCLE[task.status] ?? "todo";
|
|
||||||
try {
|
|
||||||
await apiPatch(`/api/tasks/${task.id}/status`, { status: next });
|
|
||||||
task.status = next;
|
|
||||||
if (activeTask.value?.id === task.id) activeTask.value.status = next;
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to update status", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleGroup(key: number | null) {
|
|
||||||
if (collapsedGroups.value.has(key)) {
|
|
||||||
collapsedGroups.value.delete(key);
|
|
||||||
} else {
|
|
||||||
collapsedGroups.value.add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openTask(task: Task) {
|
|
||||||
activeTask.value = task;
|
|
||||||
taskBody.value = task.body ?? "";
|
|
||||||
loadingBody.value = true;
|
|
||||||
try {
|
|
||||||
const full = await apiGet<Task>(`/api/tasks/${task.id}`);
|
|
||||||
taskBody.value = full.body ?? "";
|
|
||||||
// Update cached task so re-opens don't re-fetch if unchanged
|
|
||||||
task.body = full.body;
|
|
||||||
} catch {
|
|
||||||
// Non-critical — body just won't show
|
|
||||||
} finally {
|
|
||||||
loadingBody.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeTask() {
|
|
||||||
activeTask.value = null;
|
|
||||||
taskBody.value = "";
|
|
||||||
deleteConfirmPending.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addTask() {
|
|
||||||
const title = newTaskTitle.value.trim();
|
|
||||||
if (!title) return;
|
|
||||||
addingTask.value = true;
|
|
||||||
try {
|
|
||||||
const task = await apiPost<Task>("/api/tasks", {
|
|
||||||
title,
|
|
||||||
project_id: props.projectId,
|
|
||||||
status: "todo",
|
|
||||||
});
|
|
||||||
tasks.value.unshift(task);
|
|
||||||
newTaskTitle.value = "";
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to create task", "error");
|
|
||||||
} finally {
|
|
||||||
addingTask.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletingTask = ref(false);
|
|
||||||
const deleteConfirmPending = ref(false);
|
|
||||||
const changingMilestone = ref(false);
|
|
||||||
|
|
||||||
async function setMilestone(milestoneId: number | null) {
|
|
||||||
if (!activeTask.value) return;
|
|
||||||
changingMilestone.value = true;
|
|
||||||
try {
|
|
||||||
await apiPatch(`/api/notes/${activeTask.value.id}`, { milestone_id: milestoneId });
|
|
||||||
activeTask.value.milestone_id = milestoneId;
|
|
||||||
// Update in tasks list too
|
|
||||||
const t = tasks.value.find((x) => x.id === activeTask.value!.id);
|
|
||||||
if (t) t.milestone_id = milestoneId;
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to update milestone", "error");
|
|
||||||
} finally {
|
|
||||||
changingMilestone.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteActiveTask() {
|
|
||||||
if (!activeTask.value) return;
|
|
||||||
if (!deleteConfirmPending.value) {
|
|
||||||
deleteConfirmPending.value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deletingTask.value = true;
|
|
||||||
try {
|
|
||||||
await apiDelete(`/api/tasks/${activeTask.value.id}`);
|
|
||||||
tasks.value = tasks.value.filter((t) => t.id !== activeTask.value!.id);
|
|
||||||
activeTask.value = null;
|
|
||||||
deleteConfirmPending.value = false;
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to delete task", "error");
|
|
||||||
} finally {
|
|
||||||
deletingTask.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDeleteTask() {
|
|
||||||
deleteConfirmPending.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadAll);
|
|
||||||
defineExpose({ reload: loadAll });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="ws-task-panel">
|
|
||||||
|
|
||||||
<!-- Task list — always visible, shrinks when detail is open -->
|
|
||||||
<div :class="['task-list-view', { 'has-detail': !!activeTask }]">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">Tasks</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="task-add">
|
|
||||||
<input
|
|
||||||
v-model="newTaskTitle"
|
|
||||||
class="task-add-input"
|
|
||||||
placeholder="New task..."
|
|
||||||
@keydown.enter="addTask"
|
|
||||||
/>
|
|
||||||
<button class="btn-primary btn-inline btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="state-msg">Loading...</div>
|
|
||||||
|
|
||||||
<div v-else class="groups-scroll">
|
|
||||||
<!-- No Milestone group (always first) -->
|
|
||||||
<div class="ms-group">
|
|
||||||
<button class="ms-group-header" @click="toggleGroup(null)">
|
|
||||||
<span class="ms-chevron">{{ collapsedGroups.has(null) ? '▶' : '▼' }}</span>
|
|
||||||
<span class="ms-name">No Milestone</span>
|
|
||||||
<span class="ms-count">{{ groupedTasks.noMilestone.length }}</span>
|
|
||||||
</button>
|
|
||||||
<ul v-show="!collapsedGroups.has(null)" class="task-items">
|
|
||||||
<li
|
|
||||||
v-for="task in groupedTasks.noMilestone"
|
|
||||||
:key="task.id"
|
|
||||||
:class="['task-row', { 'task-active': activeTask?.id === task.id }]"
|
|
||||||
@click="openTask(task)"
|
|
||||||
>
|
|
||||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
|
||||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
|
||||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
|
||||||
<KindBadge :kind="task.task_kind" />
|
|
||||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
|
||||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
|
||||||
</li>
|
|
||||||
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Milestone groups -->
|
|
||||||
<div v-for="{ milestone, tasks: msTasks } in groupedTasks.milestoneGroups" :key="milestone.id" class="ms-group">
|
|
||||||
<button class="ms-group-header" @click="toggleGroup(milestone.id)">
|
|
||||||
<span class="ms-chevron">{{ collapsedGroups.has(milestone.id) ? '▶' : '▼' }}</span>
|
|
||||||
<span class="ms-name">{{ milestone.title }}</span>
|
|
||||||
<span :class="['ms-status', `ms-status-${milestone.status}`]">{{ milestone.status.replace('_',' ') }}</span>
|
|
||||||
<span class="ms-count">{{ msTasks.length }}</span>
|
|
||||||
</button>
|
|
||||||
<ul v-show="!collapsedGroups.has(milestone.id)" class="task-items">
|
|
||||||
<li
|
|
||||||
v-for="task in msTasks"
|
|
||||||
:key="task.id"
|
|
||||||
:class="['task-row', { 'task-active': activeTask?.id === task.id }]"
|
|
||||||
@click="openTask(task)"
|
|
||||||
>
|
|
||||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
|
||||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
|
||||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
|
||||||
<KindBadge :kind="task.task_kind" />
|
|
||||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
|
||||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
|
||||||
</li>
|
|
||||||
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Detail pane — bottom split when a task is selected -->
|
|
||||||
<Transition name="detail-fade">
|
|
||||||
<div v-if="activeTask" class="task-detail">
|
|
||||||
<div class="detail-header">
|
|
||||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
|
||||||
<span :class="['status-cycler', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
|
||||||
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
|
||||||
</span>
|
|
||||||
<template v-if="deleteConfirmPending">
|
|
||||||
<button class="btn-danger-outline btn-inline btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
|
||||||
<button class="btn-text" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
|
||||||
</template>
|
|
||||||
<button v-else class="btn-text btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
|
||||||
<Trash2 :size="16" />
|
|
||||||
</button>
|
|
||||||
<button class="btn-text btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="detail-title">{{ activeTask.title }}</h3>
|
|
||||||
|
|
||||||
<div class="detail-meta">
|
|
||||||
<span v-if="activeTask.priority && activeTask.priority !== 'none'" class="meta-chip priority">{{ activeTask.priority }}</span>
|
|
||||||
<span v-if="activeTask.due_date" class="meta-chip due">Due {{ activeTask.due_date }}</span>
|
|
||||||
<select
|
|
||||||
class="milestone-select"
|
|
||||||
:value="activeTask.milestone_id ?? ''"
|
|
||||||
:disabled="changingMilestone"
|
|
||||||
@change="setMilestone(($event.target as HTMLSelectElement).value === '' ? null : Number(($event.target as HTMLSelectElement).value))"
|
|
||||||
>
|
|
||||||
<option value="">No Milestone</option>
|
|
||||||
<option v-for="ms in milestones" :key="ms.id" :value="ms.id">{{ ms.title }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="taskBody || loadingBody" class="detail-body">
|
|
||||||
<div v-if="loadingBody" class="body-loading">…</div>
|
|
||||||
<div v-else class="prose" v-html="renderMarkdown(taskBody)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-log">
|
|
||||||
<TaskLogSection :task-id="activeTask.id" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ws-task-panel {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--fs-surface-hover);
|
|
||||||
border-right: 1px solid var(--fs-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── List view ── */
|
|
||||||
.task-list-view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.task-list-view.has-detail {
|
|
||||||
flex: 0 0 44%;
|
|
||||||
}
|
|
||||||
.task-active {
|
|
||||||
background: color-mix(in srgb, var(--fs-accent) 6%, var(--fs-surface-hover)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-title {
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-add {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.45rem 0.6rem;
|
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-add-input {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--fs-surface-page);
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 0.28rem 0.5rem;
|
|
||||||
font-size: 0.83rem;
|
|
||||||
color: var(--fs-text-primary);
|
|
||||||
}
|
|
||||||
.task-add-input:focus { outline: none; border-color: var(--fs-accent); }
|
|
||||||
|
|
||||||
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
|
|
||||||
|
|
||||||
.groups-scroll {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ms-group {
|
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ms-group-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.4rem 0.65rem;
|
|
||||||
background: var(--fs-surface-raised);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--fs-text-primary);
|
|
||||||
}
|
|
||||||
.ms-group-header:hover { background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover)); }
|
|
||||||
|
|
||||||
.ms-chevron { font-size: 0.6rem; color: var(--fs-text-tertiary); width: 0.8rem; }
|
|
||||||
.ms-name { flex: 1; font-weight: 500; font-size: 0.8rem; }
|
|
||||||
.ms-count { font-size: 0.72rem; color: var(--fs-text-tertiary); background: var(--fs-surface-page); border-radius: 10px; padding: 0 0.4rem; }
|
|
||||||
|
|
||||||
.ms-status {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent-fg); }
|
|
||||||
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success-fg); }
|
|
||||||
|
|
||||||
.task-items {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.35rem 0.65rem 0.35rem 1.4rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 50%, transparent);
|
|
||||||
}
|
|
||||||
.task-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
|
|
||||||
.task-row:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 1.35rem;
|
|
||||||
height: 1.35rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1.5px solid var(--fs-border-color);
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.62rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.status-dot.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); }
|
|
||||||
.status-dot.status-done { border-color: var(--fs-success); color: var(--fs-success); }
|
|
||||||
|
|
||||||
.task-title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.83rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--fs-text-primary);
|
|
||||||
}
|
|
||||||
.task-title.done { text-decoration: line-through; color: var(--fs-text-tertiary); }
|
|
||||||
|
|
||||||
.task-age {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
|
||||||
|
|
||||||
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--fs-text-tertiary); }
|
|
||||||
|
|
||||||
/* ── Detail pane (bottom split) ── */
|
|
||||||
.task-detail {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--fs-surface-hover);
|
|
||||||
border-top: 2px solid var(--fs-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* An interactive CYCLER, not a chip: it is clickable, outlined and
|
|
||||||
transparent. It shared a name with the task chip and was never the same
|
|
||||||
shape (#3132). */
|
|
||||||
.status-cycler {
|
|
||||||
padding: 0.2rem 0.55rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1.5px solid var(--fs-border-color);
|
|
||||||
background: none;
|
|
||||||
text-transform: capitalize;
|
|
||||||
user-select: none;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.status-cycler.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
|
|
||||||
.status-cycler.status-done { border-color: var(--fs-success); color: var(--fs-success-fg); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
|
|
||||||
|
|
||||||
.btn-edit-task { margin-left: 0.25rem; }
|
|
||||||
.btn-edit-task:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.detail-body {
|
|
||||||
padding: 0.5rem 0.75rem 0.5rem;
|
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
|
||||||
flex-shrink: 0;
|
|
||||||
max-height: 40%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body-loading {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-body .prose {
|
|
||||||
font-size: 0.83rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--fs-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete-task { margin-left: 0.25rem; }
|
|
||||||
.btn-delete-task:hover { color: var(--fs-action-destructive); }
|
|
||||||
|
|
||||||
.btn-delete-confirm { margin-left: 0.25rem; }
|
|
||||||
|
|
||||||
|
|
||||||
.detail-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0 0.75rem 0.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-chip {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--fs-surface-page);
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.milestone-select {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
padding: 0.15rem 0.4rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--fs-surface-page);
|
|
||||||
border: 1px solid var(--fs-border-color);
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
cursor: pointer;
|
|
||||||
max-width: 140px;
|
|
||||||
}
|
|
||||||
.milestone-select:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.milestone-select:focus { outline: none; border-color: var(--fs-accent); }
|
|
||||||
|
|
||||||
.detail-log {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0 0.6rem 0.6rem;
|
|
||||||
border-top: 1px solid var(--fs-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Detail fade transition */
|
|
||||||
.detail-fade-enter-active,
|
|
||||||
.detail-fade-leave-active { transition: opacity 0.15s; }
|
|
||||||
.detail-fade-enter-from,
|
|
||||||
.detail-fade-leave-to { opacity: 0; }
|
|
||||||
|
|
||||||
/* Priority dots on task rows */
|
|
||||||
.priority-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.priority-high { background: #ef4444; }
|
|
||||||
.priority-medium { background: #f59e0b; }
|
|
||||||
.priority-low { background: #3b82f6; }
|
|
||||||
|
|
||||||
/* Due date on task rows */
|
|
||||||
.task-due {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.task-due.overdue {
|
|
||||||
color: var(--fs-error);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Close detail button */
|
|
||||||
.btn-close-detail { margin-left: 0.2rem; }
|
|
||||||
</style>
|
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
* full. Not on the list row: a history entry point there would compete with
|
* full. Not on the list row: a history entry point there would compete with
|
||||||
* the row's actual job.
|
* the row's actual job.
|
||||||
*
|
*
|
||||||
* A SIBLING OF HistoryPanel.vue, NOT A REUSE OF IT, and the reason is in its
|
* A SIBLING OF VersionHistorySection.vue (a note's history), NOT A REUSE OF
|
||||||
* props: `noteId` + `currentBody`, a `NoteVersion` carrying tags and pin
|
* IT, and the reason is in its props: `noteId` + `currentBody`, a note
|
||||||
* columns, a fetch of /api/notes/…, a `restore` emit, and pin/unpin buttons.
|
* version carrying tags, a fetch of /api/notes/…, and a `restore` emit.
|
||||||
* Every one of those is note-shaped. Rules have no tags, no pins, and
|
* Every one of those is note-shaped. Rules have no tags, no pins, and
|
||||||
* deliberately no restore, and a rule's text is EIGHT fields rather than one
|
* deliberately no restore, and a rule's text is EIGHT fields rather than one
|
||||||
* body — which changes the central question from "what changed" to "which
|
* body — which changes the central question from "what changed" to "which
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createApp } from "vue";
|
|||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
|
import "./assets/fonts/fonts.css";
|
||||||
import "./assets/theme.css";
|
import "./assets/theme.css";
|
||||||
// After theme.css — it consumes the tokens declared there.
|
// After theme.css — it consumes the tokens declared there.
|
||||||
import "./assets/components.css";
|
import "./assets/components.css";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Line diff — one copy, for every surface that shows what changed.
|
* Line diff — one copy, for every surface that shows what changed.
|
||||||
*
|
*
|
||||||
* WHY THIS FILE EXISTS. The one LCS diff walk, shared by `useAssist.ts`,
|
* WHY THIS FILE EXISTS. The one LCS diff walk, shared by `useAssist.ts`,
|
||||||
* `HistoryPanel.vue`, `VersionHistorySection.vue` and the rule edit history.
|
* `VersionHistorySection.vue` and the rule edit history.
|
||||||
* A copy per consumer means a fix has to be found in N places by someone who
|
* A copy per consumer means a fix has to be found in N places by someone who
|
||||||
* does not know N.
|
* does not know N.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type FamilyIdea, type IdeaSnapshot, type IdeaStatus,
|
type FamilyIdea, type IdeaSnapshot, type IdeaStatus,
|
||||||
} from "@/api/family";
|
} from "@/api/family";
|
||||||
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
|
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
|
||||||
|
import ScrollSentinel from "@/components/ScrollSentinel.vue";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { fmtStamp } from "@/utils/dateFormat";
|
import { fmtStamp } from "@/utils/dateFormat";
|
||||||
import { recordHref } from "@/utils/recordHref";
|
import { recordHref } from "@/utils/recordHref";
|
||||||
@@ -36,6 +37,8 @@ const loading = ref(true);
|
|||||||
const failed = ref(false);
|
const failed = ref(false);
|
||||||
const moreDecisions = ref(false);
|
const moreDecisions = ref(false);
|
||||||
const loadingMore = ref(false);
|
const loadingMore = ref(false);
|
||||||
|
const moreFailed = ref(false);
|
||||||
|
const decisionOffset = ref(0);
|
||||||
|
|
||||||
// One pending reversal at a time: which row, which act, and its reason.
|
// One pending reversal at a time: which row, which act, and its reason.
|
||||||
const pending = ref<{ kind: "undo" | "retire"; id: number } | null>(null);
|
const pending = ref<{ kind: "undo" | "retire"; id: number } | null>(null);
|
||||||
@@ -63,7 +66,9 @@ async function load() {
|
|||||||
criteria.value = ideaData.criteria;
|
criteria.value = ideaData.criteria;
|
||||||
conflictOrder.value = ideaData.conflict_order ?? [];
|
conflictOrder.value = ideaData.conflict_order ?? [];
|
||||||
decisions.value = log;
|
decisions.value = log;
|
||||||
|
decisionOffset.value = log.length;
|
||||||
moreDecisions.value = log.length === PAGE;
|
moreDecisions.value = log.length === PAGE;
|
||||||
|
moreFailed.value = false;
|
||||||
} catch {
|
} catch {
|
||||||
// Said out loud: a log that failed to load must not read as "nothing
|
// Said out loud: a log that failed to load must not read as "nothing
|
||||||
// has been decided".
|
// has been decided".
|
||||||
@@ -73,14 +78,20 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Older decisions load as the log is scrolled. The log is offset-paged, so a
|
||||||
|
// decision recorded since the first page shifts the rest down by one; a row
|
||||||
|
// already shown is skipped, and the offset still counts what the server sent.
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
loadingMore.value = true;
|
loadingMore.value = true;
|
||||||
|
moreFailed.value = false;
|
||||||
try {
|
try {
|
||||||
const next = await listFamilyDecisions(PAGE, decisions.value.length);
|
const next = await listFamilyDecisions(PAGE, decisionOffset.value);
|
||||||
decisions.value.push(...next);
|
const shown = new Set(decisions.value.map((d) => d.id));
|
||||||
|
decisions.value.push(...next.filter((d) => !shown.has(d.id)));
|
||||||
|
decisionOffset.value += next.length;
|
||||||
moreDecisions.value = next.length === PAGE;
|
moreDecisions.value = next.length === PAGE;
|
||||||
} catch (e: unknown) {
|
} catch {
|
||||||
toast.show(apiErrorMessage(e, "Could not load more decisions"), "error");
|
moreFailed.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
loadingMore.value = false;
|
loadingMore.value = false;
|
||||||
}
|
}
|
||||||
@@ -343,9 +354,12 @@ onMounted(load);
|
|||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button v-if="moreDecisions" class="btn-secondary btn-sm" :disabled="loadingMore" @click="loadMore">
|
<ScrollSentinel
|
||||||
{{ loadingMore ? "Loading…" : "Older decisions" }}
|
:cursor="moreDecisions ? String(decisionOffset) : ''"
|
||||||
</button>
|
:loading="loadingMore"
|
||||||
|
:failed="moreFailed"
|
||||||
|
@more="loadMore"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note";
|
import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note";
|
||||||
@@ -10,6 +10,7 @@ import UsageBadge from "@/components/UsageBadge.vue";
|
|||||||
import NoteSweepPane from "@/components/NoteSweepPane.vue";
|
import NoteSweepPane from "@/components/NoteSweepPane.vue";
|
||||||
import StatusBadge from "@/components/StatusBadge.vue";
|
import StatusBadge from "@/components/StatusBadge.vue";
|
||||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||||
|
import ScrollSentinel from "@/components/ScrollSentinel.vue";
|
||||||
import GraphView from "@/views/GraphView.vue";
|
import GraphView from "@/views/GraphView.vue";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
@@ -193,7 +194,7 @@ function onClickOutsideNewNote(e: MouseEvent) {
|
|||||||
|
|
||||||
const ID_BATCH = 100; // IDs fetched per server round-trip
|
const ID_BATCH = 100; // IDs fetched per server round-trip
|
||||||
const INITIAL_CONTENT = 50; // items loaded on first render
|
const INITIAL_CONTENT = 50; // items loaded on first render
|
||||||
const CONTENT_PAGE = 24; // items loaded per sentinel trigger
|
const CONTENT_PAGE = 24; // items loaded each time the end of the feed nears
|
||||||
const REFILL_THRESHOLD = 48; // fetch more IDs when queue drops below this
|
const REFILL_THRESHOLD = 48; // fetch more IDs when queue drops below this
|
||||||
|
|
||||||
const items = ref<KnowledgeItem[]>([]);
|
const items = ref<KnowledgeItem[]>([]);
|
||||||
@@ -214,11 +215,21 @@ const totalKnowledge = ref(0); // total matching items on server
|
|||||||
const allIdsFetched = ref(false); // no more ID pages to fetch
|
const allIdsFetched = ref(false); // no more ID pages to fetch
|
||||||
const idsFetching = ref(false); // ID batch request in-flight
|
const idsFetching = ref(false); // ID batch request in-flight
|
||||||
const contentFetching = ref(false); // content batch request in-flight
|
const contentFetching = ref(false); // content batch request in-flight
|
||||||
const sentinelVisible = ref(false); // updated by IntersectionObserver
|
const moreFailed = ref(false); // the last page asked for did not load
|
||||||
|
|
||||||
const loading = computed(() => idsFetching.value || contentFetching.value);
|
const loading = computed(() => idsFetching.value || contentFetching.value);
|
||||||
|
|
||||||
let fetchGen = 0; // incremented on each reset to invalidate stale responses
|
let fetchGen = 0; // incremented on each reset to invalidate stale responses
|
||||||
|
const feedGen = ref(0); // fetchGen, readable by the template
|
||||||
|
|
||||||
|
// What the next page is keyed on: the feed it belongs to and how many of its ids
|
||||||
|
// have been loaded. Ids, not items: a batch can return fewer records than it
|
||||||
|
// asked for, and the feed must still move on. Empty once every id is loaded.
|
||||||
|
const nextCursor = computed(() =>
|
||||||
|
idQueue.value.length > 0 || !allIdsFetched.value
|
||||||
|
? `${feedGen.value}:${idOffset.value - idQueue.value.length}`
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
function buildFilterParams(): URLSearchParams {
|
function buildFilterParams(): URLSearchParams {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
@@ -244,7 +255,9 @@ async function fetchIdBatch(gen: number) {
|
|||||||
idOffset.value += data.ids.length;
|
idOffset.value += data.ids.length;
|
||||||
totalKnowledge.value = data.total;
|
totalKnowledge.value = data.total;
|
||||||
if (!data.has_more) allIdsFetched.value = true;
|
if (!data.has_more) allIdsFetched.value = true;
|
||||||
} catch { /* silent */ }
|
} catch {
|
||||||
|
if (gen === fetchGen) moreFailed.value = true;
|
||||||
|
}
|
||||||
finally { if (gen === fetchGen) idsFetching.value = false; }
|
finally { if (gen === fetchGen) idsFetching.value = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,26 +275,38 @@ async function loadNextContent(gen: number, count: number) {
|
|||||||
if (idQueue.value.length < REFILL_THRESHOLD && !allIdsFetched.value && !idsFetching.value) {
|
if (idQueue.value.length < REFILL_THRESHOLD && !allIdsFetched.value && !idsFetching.value) {
|
||||||
fetchIdBatch(gen);
|
fetchIdBatch(gen);
|
||||||
}
|
}
|
||||||
} catch { /* silent */ }
|
} catch {
|
||||||
finally {
|
// The ids go back to the front of the queue, so Try again asks for the
|
||||||
if (gen !== fetchGen) return;
|
// same records rather than skipping past them.
|
||||||
contentFetching.value = false;
|
if (gen === fetchGen) {
|
||||||
// Sentinel may still be visible if new items didn't push it off screen
|
idQueue.value.unshift(...toLoad);
|
||||||
await nextTick();
|
moreFailed.value = true;
|
||||||
if (sentinelVisible.value && idQueue.value.length > 0 && gen === fetchGen) {
|
|
||||||
loadNextContent(gen, CONTENT_PAGE);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally {
|
||||||
|
if (gen === fetchGen) contentFetching.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next page of the feed: more ids first when the queue has run dry.
|
||||||
|
async function loadMore() {
|
||||||
|
const gen = fetchGen;
|
||||||
|
moreFailed.value = false;
|
||||||
|
if (idQueue.value.length === 0) await fetchIdBatch(gen);
|
||||||
|
if (gen !== fetchGen) return;
|
||||||
|
await loadNextContent(gen, CONTENT_PAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reset() {
|
async function reset() {
|
||||||
fetchGen++;
|
fetchGen++;
|
||||||
|
feedGen.value = fetchGen;
|
||||||
const gen = fetchGen;
|
const gen = fetchGen;
|
||||||
items.value = [];
|
items.value = [];
|
||||||
idQueue.value = [];
|
idQueue.value = [];
|
||||||
idOffset.value = 0;
|
idOffset.value = 0;
|
||||||
totalKnowledge.value = 0;
|
totalKnowledge.value = 0;
|
||||||
allIdsFetched.value = false;
|
allIdsFetched.value = false;
|
||||||
|
moreFailed.value = false;
|
||||||
await fetchIdBatch(gen);
|
await fetchIdBatch(gen);
|
||||||
if (gen !== fetchGen) return;
|
if (gen !== fetchGen) return;
|
||||||
await loadNextContent(gen, INITIAL_CONTENT);
|
await loadNextContent(gen, INITIAL_CONTENT);
|
||||||
@@ -294,23 +319,16 @@ async function fetchTags() {
|
|||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetAndReobserve() {
|
|
||||||
await reset();
|
|
||||||
await nextTick();
|
|
||||||
setupObserver();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearchInput() {
|
function onSearchInput() {
|
||||||
if (searchDebounce) clearTimeout(searchDebounce);
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
searchDebounce = setTimeout(() => resetAndReobserve(), 380);
|
searchDebounce = setTimeout(() => reset(), 380);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([activeType, sortMode], () => resetAndReobserve());
|
watch([activeType, sortMode], () => reset());
|
||||||
// Closing the sweep remounts the feed, and with it the scroll sentinel — a
|
// Closing the sweep starts the feed over, so a note confirmed in the sweep is
|
||||||
// fresh element the old observer is not watching. Without this the list loads
|
// shown as it now stands.
|
||||||
// its first page and then never loads another.
|
watch(sweepActive, (open) => { if (!open) reset(); });
|
||||||
watch(sweepActive, (open) => { if (!open) resetAndReobserve(); });
|
watch(activeTag, () => { fetchCounts(); reset(); });
|
||||||
watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
|
||||||
|
|
||||||
// ─── Today bar ────────────────────────────────────────────────────────────────
|
// ─── Today bar ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -377,24 +395,6 @@ function formatDate(iso: string): string {
|
|||||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sentinel observer ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const sentinelEl = ref<HTMLElement | null>(null);
|
|
||||||
let observer: IntersectionObserver | null = null;
|
|
||||||
|
|
||||||
function setupObserver() {
|
|
||||||
observer?.disconnect();
|
|
||||||
if (!sentinelEl.value) return;
|
|
||||||
observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
sentinelVisible.value = entry.isIntersecting;
|
|
||||||
if (entry.isIntersecting) loadNextContent(fetchGen, CONTENT_PAGE);
|
|
||||||
},
|
|
||||||
{ root: null, rootMargin: "200px" }
|
|
||||||
);
|
|
||||||
observer.observe(sentinelEl.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -403,14 +403,11 @@ onMounted(async () => {
|
|||||||
fetchTags();
|
fetchTags();
|
||||||
fetchCounts();
|
fetchCounts();
|
||||||
fetchTodayBar();
|
fetchTodayBar();
|
||||||
await nextTick();
|
|
||||||
setupObserver();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', onClickOutsideNewNote);
|
document.removeEventListener('click', onClickOutsideNewNote);
|
||||||
if (searchDebounce) clearTimeout(searchDebounce);
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
observer?.disconnect();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -583,10 +580,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The whole feed stands down while the sweep is open: two answers
|
<!-- The whole feed stands down while the sweep is open: two answers
|
||||||
to two different questions on one screen is neither. Wrapped
|
to two different questions on one screen is neither. -->
|
||||||
rather than given an extra v-if branch, because the scroll
|
|
||||||
sentinel lives inside the grid and the observer must not be left
|
|
||||||
holding a ref to something that never renders. -->
|
|
||||||
<template v-if="!sweepActive">
|
<template v-if="!sweepActive">
|
||||||
<!-- Loading / empty -->
|
<!-- Loading / empty -->
|
||||||
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
|
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
|
||||||
@@ -675,10 +669,12 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sentinel — IntersectionObserver triggers next content batch -->
|
<ScrollSentinel
|
||||||
<div ref="sentinelEl" class="scroll-sentinel">
|
:cursor="nextCursor"
|
||||||
<span v-if="contentFetching" class="sentinel-loading">Loading…</span>
|
:loading="loading"
|
||||||
</div>
|
:failed="moreFailed"
|
||||||
|
@more="loadMore"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -1115,19 +1111,6 @@ onUnmounted(() => {
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sentinel ────────────────────────────────────────────── */
|
|
||||||
.scroll-sentinel {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.sentinel-loading {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Graph panel ─────────────────────────────────────────── */
|
/* ── Graph panel ─────────────────────────────────────────── */
|
||||||
.graph-panel {
|
.graph-panel {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGrou
|
|||||||
import type { User } from "@/types/auth";
|
import type { User } from "@/types/auth";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
import MomentsSettings from "@/components/MomentsSettings.vue";
|
import MomentsSettings from "@/components/MomentsSettings.vue";
|
||||||
|
import ReverseProxySettings from "@/components/ReverseProxySettings.vue";
|
||||||
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
|
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
|
||||||
import BackupScheduleSettings from "@/components/BackupScheduleSettings.vue";
|
import BackupScheduleSettings from "@/components/BackupScheduleSettings.vue";
|
||||||
|
import RestoreBackupDialog from "@/components/RestoreBackupDialog.vue";
|
||||||
import { formatBytes } from "@/utils/bytes";
|
import { formatBytes } from "@/utils/bytes";
|
||||||
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||||||
import { fetchVersion, type VersionPayload } from "@/api/version";
|
import { fetchVersion, type VersionPayload } from "@/api/version";
|
||||||
@@ -159,16 +161,10 @@ const kbWritePathEnabled = ref(true);
|
|||||||
// code embeddings sit on a much higher similarity floor than prose, so 0.55
|
// code embeddings sit on a much higher similarity floor than prose, so 0.55
|
||||||
// lets unrelated code through. Shares top-k, not the threshold.
|
// lets unrelated code through. Shares top-k, not the threshold.
|
||||||
const kbWritePathThreshold = ref("0.68");
|
const kbWritePathThreshold = ref("0.68");
|
||||||
const kbRuleHintThreshold = ref("0.72");
|
// One bar for every rule hint — prompt, command and edit alike. They search
|
||||||
|
// the same rules, and what limits each is its budget, not the bar.
|
||||||
|
const kbRuleThreshold = ref("0.70");
|
||||||
const kbCheckpointThreshold = ref("0.80");
|
const kbCheckpointThreshold = ref("0.80");
|
||||||
// The two ACT arms do not share a bar. A write-path query is a code payload; a
|
|
||||||
// pre-tool query is a shell command, often under a dozen words — less text,
|
|
||||||
// less signal, lower scores for the same relevance — so one shared bar makes
|
|
||||||
// them behave like different subsystems.
|
|
||||||
const kbToolRuleThreshold = ref("0.68");
|
|
||||||
// And the prompt boundary is a third query shape again — the operator's own
|
|
||||||
// prose rather than anything a tool produced.
|
|
||||||
const kbPromptRuleThreshold = ref("0.72");
|
|
||||||
// The reply arm: its bar is the bar at which a finished reply
|
// The reply arm: its bar is the bar at which a finished reply
|
||||||
// is HELD for one read, so it sits with the checkpoint, not with the hints.
|
// is HELD for one read, so it sits with the checkpoint, not with the hints.
|
||||||
const kbReplyRuleThreshold = ref("0.8");
|
const kbReplyRuleThreshold = ref("0.8");
|
||||||
@@ -367,14 +363,10 @@ async function saveKbInject() {
|
|||||||
// Same `|| default` reasoning: falling back to 0 would surface every
|
// Same `|| default` reasoning: falling back to 0 would surface every
|
||||||
// snippet in the corpus on every edit, which is the failure this knob fixes.
|
// snippet in the corpus on every edit, which is the failure this knob fixes.
|
||||||
const wpT = asBar(kbWritePathThreshold.value, 0.68);
|
const wpT = asBar(kbWritePathThreshold.value, 0.68);
|
||||||
// Same `|| default` reasoning again, and it bites harder here: a rule hint
|
// Same `|| default` reasoning again, and it bites harder here: rule hints
|
||||||
// fires on every write, so a fallback of 0 would attach a standing rule to
|
// fire on every edit and before every command, so a fallback of 0 would put
|
||||||
// every edit in the session.
|
// a standing rule beside each of them.
|
||||||
const rhT = asBar(kbRuleHintThreshold.value, 0.72);
|
const ruleT = asBar(kbRuleThreshold.value, 0.7);
|
||||||
// Same `|| default` guard, and the same reason: this arm fires before every
|
|
||||||
// Bash call, so a fallback of 0 would put a rule in front of every command.
|
|
||||||
const trT = asBar(kbToolRuleThreshold.value, 0.68);
|
|
||||||
const prT = asBar(kbPromptRuleThreshold.value, 0.72);
|
|
||||||
// The checkpoint bar, and the `|| default` guard matters most here of all:
|
// The checkpoint bar, and the `|| default` guard matters most here of all:
|
||||||
// this is the only number that can STOP a call, so a fallback of 0 would
|
// this is the only number that can STOP a call, so a fallback of 0 would
|
||||||
// hold the first command of every session behind whatever ranked first.
|
// hold the first command of every session behind whatever ranked first.
|
||||||
@@ -409,10 +401,8 @@ async function saveKbInject() {
|
|||||||
kbGateNoteOverlapFloor.value = String(gateOverlap);
|
kbGateNoteOverlapFloor.value = String(gateOverlap);
|
||||||
kbPlanMatchThreshold.value = String(planT);
|
kbPlanMatchThreshold.value = String(planT);
|
||||||
kbWritePathThreshold.value = String(wpT);
|
kbWritePathThreshold.value = String(wpT);
|
||||||
kbRuleHintThreshold.value = String(rhT);
|
kbRuleThreshold.value = String(ruleT);
|
||||||
kbCheckpointThreshold.value = String(cpT);
|
kbCheckpointThreshold.value = String(cpT);
|
||||||
kbToolRuleThreshold.value = String(trT);
|
|
||||||
kbPromptRuleThreshold.value = String(prT);
|
|
||||||
kbReplyRuleThreshold.value = String(ryT);
|
kbReplyRuleThreshold.value = String(ryT);
|
||||||
savingKbInject.value = true;
|
savingKbInject.value = true;
|
||||||
kbInjectSaved.value = false;
|
kbInjectSaved.value = false;
|
||||||
@@ -426,20 +416,15 @@ async function saveKbInject() {
|
|||||||
// measurements that split them.
|
// measurements that split them.
|
||||||
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
||||||
kb_writepath_threshold: String(wpT),
|
kb_writepath_threshold: String(wpT),
|
||||||
// A THIRD corpus with a third bar — see RULEHINT_DEFAULT_THRESHOLD
|
// A third corpus with a third bar — see RULE_FLOOR_DEFAULT in
|
||||||
// in services/plugin_context.py for why rules cannot share the
|
// services/retrieval_pipeline.py for why every rule hint shares it.
|
||||||
// code threshold any more than code could share the prose one.
|
kb_rule_threshold: String(ruleT),
|
||||||
kb_rulehint_threshold: String(rhT),
|
|
||||||
// The one bar that stops a call rather than annotating it. Its own key,
|
// The one bar that stops a call rather than annotating it. Its own key,
|
||||||
// never derived from the two rule bars above: it answers a different
|
// never derived from the rule bar above: it answers a different
|
||||||
// question of the same scores — not "is this worth showing" but "is the
|
// question of the same scores — not "is this worth showing" but "is the
|
||||||
// corpus confident enough to be read first" — and a number that moves
|
// corpus confident enough to be read first" — and a number that moves
|
||||||
// when another moves is a number nobody can reason about.
|
// when another moves is a number nobody can reason about.
|
||||||
kb_checkpoint_threshold: String(cpT),
|
kb_checkpoint_threshold: String(cpT),
|
||||||
// A FOURTH and FIFTH bar, and they are separate keys on purpose: one
|
|
||||||
// number cannot serve arms whose queries are different shapes. Moving one must not move the others.
|
|
||||||
kb_toolrule_threshold: String(trT),
|
|
||||||
kb_promptrule_threshold: String(prT),
|
|
||||||
// The reply backstop's own bar: it HOLDS a reply, so like the
|
// The reply backstop's own bar: it HOLDS a reply, so like the
|
||||||
// checkpoint it is never derived from a hint bar.
|
// checkpoint it is never derived from a hint bar.
|
||||||
kb_replyrule_threshold: String(ryT),
|
kb_replyrule_threshold: String(ryT),
|
||||||
@@ -484,7 +469,7 @@ const confirmNewPassword = ref("");
|
|||||||
const changingPassword = ref(false);
|
const changingPassword = ref(false);
|
||||||
const invalidatingSessions = ref(false);
|
const invalidatingSessions = ref(false);
|
||||||
const exporting = ref(false);
|
const exporting = ref(false);
|
||||||
const restoring = ref(false);
|
const showRestore = ref(false);
|
||||||
// Backup, export and restore walk the whole store, so they are slow BY DESIGN
|
// Backup, export and restore walk the whole store, so they are slow BY DESIGN
|
||||||
// and the client's ordinary 30s default would cut them off mid-work. They are
|
// and the client's ordinary 30s default would cut them off mid-work. They are
|
||||||
// still bounded — every wait has a deadline, not necessarily a short one — and
|
// still bounded — every wait has a deadline, not necessarily a short one — and
|
||||||
@@ -526,7 +511,6 @@ async function copyCommit() {
|
|||||||
commitCopied.value = true;
|
commitCopied.value = true;
|
||||||
setTimeout(() => { commitCopied.value = false; }, 2000);
|
setTimeout(() => { commitCopied.value = false; }, 2000);
|
||||||
}
|
}
|
||||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||||
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
|
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
|
||||||
@@ -864,14 +848,8 @@ onMounted(async () => {
|
|||||||
if (allSettings.kb_checkpoint_threshold !== undefined) {
|
if (allSettings.kb_checkpoint_threshold !== undefined) {
|
||||||
kbCheckpointThreshold.value = allSettings.kb_checkpoint_threshold;
|
kbCheckpointThreshold.value = allSettings.kb_checkpoint_threshold;
|
||||||
}
|
}
|
||||||
if (allSettings.kb_rulehint_threshold !== undefined) {
|
if (allSettings.kb_rule_threshold !== undefined) {
|
||||||
kbRuleHintThreshold.value = allSettings.kb_rulehint_threshold;
|
kbRuleThreshold.value = allSettings.kb_rule_threshold;
|
||||||
}
|
|
||||||
if (allSettings.kb_toolrule_threshold !== undefined) {
|
|
||||||
kbToolRuleThreshold.value = allSettings.kb_toolrule_threshold;
|
|
||||||
}
|
|
||||||
if (allSettings.kb_promptrule_threshold !== undefined) {
|
|
||||||
kbPromptRuleThreshold.value = allSettings.kb_promptrule_threshold;
|
|
||||||
}
|
}
|
||||||
if (allSettings.kb_replyrule_threshold !== undefined) {
|
if (allSettings.kb_replyrule_threshold !== undefined) {
|
||||||
kbReplyRuleThreshold.value = allSettings.kb_replyrule_threshold;
|
kbReplyRuleThreshold.value = allSettings.kb_replyrule_threshold;
|
||||||
@@ -1102,10 +1080,6 @@ async function exportNotes(format: "markdown" | "json") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerRestoreUpload() {
|
|
||||||
restoreFileInput.value?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveNotifications() {
|
async function saveNotifications() {
|
||||||
savingNotifications.value = true;
|
savingNotifications.value = true;
|
||||||
notificationsSaved.value = false;
|
notificationsSaved.value = false;
|
||||||
@@ -1302,42 +1276,6 @@ async function runDbMaintenanceNow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRestoreFile(event: Event) {
|
|
||||||
const file = (event.target as HTMLInputElement).files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
restoring.value = true;
|
|
||||||
try {
|
|
||||||
// The scheduled backup's .tar goes up as it is; the JSON download is
|
|
||||||
// parsed first so a damaged file fails here, before the upload.
|
|
||||||
const isTar = file.name.toLowerCase().endsWith(".tar");
|
|
||||||
const res = await fetch("/api/admin/restore", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": isTar ? "application/x-tar" : "application/json" },
|
|
||||||
body: isTar ? file : JSON.stringify(JSON.parse(await file.text())),
|
|
||||||
signal: bulkDeadline(),
|
|
||||||
});
|
|
||||||
if (res.status === 413) {
|
|
||||||
throw new Error(
|
|
||||||
"the file is bigger than the web upload allows. Restore it with " +
|
|
||||||
"`python -m grimoire.restore <file>` inside the Grimoire container instead.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
|
||||||
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
|
||||||
}
|
|
||||||
const result = await res.json();
|
|
||||||
toastStore.show(
|
|
||||||
`Restored ${result.stats?.users ?? 0} users, ${result.stats?.notes ?? 0} notes, ${result.stats?.conversations ?? 0} conversations`
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
toastStore.show("Restore failed: " + (e as Error).message, "error");
|
|
||||||
} finally {
|
|
||||||
restoring.value = false;
|
|
||||||
if (restoreFileInput.value) restoreFileInput.value.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testSearch() {
|
async function testSearch() {
|
||||||
const q = searchQuery.value.trim();
|
const q = searchQuery.value.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
@@ -1772,10 +1710,10 @@ async function deleteUser(userId: number) {
|
|||||||
<p class="field-hint">How many snippets and issues one edit may be offered (1–10). Reach for this rather than the threshold when the hint feels long: lowering it keeps the best matches and drops the tail, while raising the bar above throws away good matches along with weak ones.</p>
|
<p class="field-hint">How many snippets and issues one edit may be offered (1–10). Reach for this rather than the threshold when the hint feels long: lowering it keeps the best matches and drops the tail, while raising the bar above throws away good matches along with weak ones.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="kb-rulehint-threshold">Standing-rule confidence threshold (0–1)</label>
|
<label for="kb-rule-threshold">Standing-rule confidence threshold (0–1)</label>
|
||||||
<input
|
<input
|
||||||
id="kb-rulehint-threshold"
|
id="kb-rule-threshold"
|
||||||
v-model="kbRuleHintThreshold"
|
v-model="kbRuleThreshold"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="1"
|
||||||
@@ -1784,14 +1722,14 @@ async function deleteUser(userId: number) {
|
|||||||
style="max-width: 8rem"
|
style="max-width: 8rem"
|
||||||
/>
|
/>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
The same hint can mention a standing rule whose trigger resembles the
|
The bar for a standing rule or preference to be offered at all,
|
||||||
code being written. <em>Every</em> rule is eligible — nothing is
|
whether against the code being written, a command about to run, or
|
||||||
preloaded any more, so this is the only way a rule reaches a write.
|
what you just said. <em>Every</em> rule is eligible — nothing is
|
||||||
Stricter than the threshold above, because there are far fewer rules
|
preloaded, so this is how a rule reaches the work. One bar serves all
|
||||||
than snippets: with a small set something always ranks first, so the
|
three: they search the same rules, and how many each may show is set
|
||||||
bar has to carry more of the judgement. If rules arrive unread, or one
|
by its own budget below, which is usually the better lever. If rules
|
||||||
you needed never showed up, that is Claude's to notice and correct
|
arrive unread, or one you needed never showed up, that is Claude's to
|
||||||
from the telemetry — and the budget below is usually the better lever.
|
notice and correct from the telemetry.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -1834,26 +1772,6 @@ async function deleteUser(userId: number) {
|
|||||||
only — file edits are never held.
|
only — file edits are never held.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label for="kb-toolrule-threshold">Command confidence threshold (0–1)</label>
|
|
||||||
<input
|
|
||||||
id="kb-toolrule-threshold"
|
|
||||||
v-model="kbToolRuleThreshold"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.01"
|
|
||||||
class="fs-input input"
|
|
||||||
style="max-width: 8rem"
|
|
||||||
/>
|
|
||||||
<p class="field-hint">
|
|
||||||
The bar for a rule surfacing before a <em>command</em> runs, where the
|
|
||||||
query is the command text rather than code. Lower than the one above
|
|
||||||
on purpose: a shell command is short, so it scores lower for the same
|
|
||||||
relevance — at a shared bar this arm spoke on 2% of calls against the
|
|
||||||
write path's 37%.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="kb-toolrule-topk">Max rules per command</label>
|
<label for="kb-toolrule-topk">Max rules per command</label>
|
||||||
<input
|
<input
|
||||||
@@ -1868,26 +1786,6 @@ async function deleteUser(userId: number) {
|
|||||||
/>
|
/>
|
||||||
<p class="field-hint">How many standing rules one command may be shown (1–10). This is the busiest arm there is — it fires before every command — so its budget is the one most worth keeping small.</p>
|
<p class="field-hint">How many standing rules one command may be shown (1–10). This is the busiest arm there is — it fires before every command — so its budget is the one most worth keeping small.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label for="kb-promptrule-threshold">Prompt confidence threshold (0–1)</label>
|
|
||||||
<input
|
|
||||||
id="kb-promptrule-threshold"
|
|
||||||
v-model="kbPromptRuleThreshold"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.01"
|
|
||||||
class="fs-input input"
|
|
||||||
style="max-width: 8rem"
|
|
||||||
/>
|
|
||||||
<p class="field-hint">
|
|
||||||
The bar for a rule or preference surfacing against <em>what you just
|
|
||||||
said</em>, before anything is done. It is the only moment that reaches
|
|
||||||
a rule about how to answer rather than how to act, so it runs once a
|
|
||||||
turn rather than once a tool call. A third query shape — your prose,
|
|
||||||
not a command or a file — which is why it carries its own number.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="kb-promptrule-topk">Max rules per prompt</label>
|
<label for="kb-promptrule-topk">Max rules per prompt</label>
|
||||||
<input
|
<input
|
||||||
@@ -2492,17 +2390,11 @@ async function deleteUser(userId: number) {
|
|||||||
<button class="btn-secondary" @click="exportData('full')" :disabled="exporting">
|
<button class="btn-secondary" @click="exportData('full')" :disabled="exporting">
|
||||||
{{ exporting ? "Exporting..." : "Full Backup" }}
|
{{ exporting ? "Exporting..." : "Full Backup" }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-secondary btn-warn" @click="triggerRestoreUpload" :disabled="restoring">
|
<button class="btn-secondary btn-warn" @click="showRestore = true">
|
||||||
{{ restoring ? "Restoring..." : "Restore from Backup" }}
|
Restore from Backup
|
||||||
</button>
|
</button>
|
||||||
<input
|
|
||||||
ref="restoreFileInput"
|
|
||||||
type="file"
|
|
||||||
accept=".json,.tar"
|
|
||||||
class="hidden-file-input"
|
|
||||||
@change="handleRestoreFile"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<RestoreBackupDialog v-if="showRestore" @close="showRestore = false" />
|
||||||
</section>
|
</section>
|
||||||
<section class="settings-section full-width">
|
<section class="settings-section full-width">
|
||||||
<h2>Scheduled backups</h2>
|
<h2>Scheduled backups</h2>
|
||||||
@@ -2773,6 +2665,16 @@ async function deleteUser(userId: number) {
|
|||||||
<div v-else class="empty-msg">Nothing asked yet.</div>
|
<div v-else class="empty-msg">Nothing asked yet.</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Reverse proxy</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
How many proxies a request passes through before it reaches Grimoire. Sign-in rate
|
||||||
|
limits and the audit log key on the visitor's address, read through this count; if it
|
||||||
|
is too low, every visitor reads as the proxy and one run of bad sign-ins blocks everyone.
|
||||||
|
</p>
|
||||||
|
<ReverseProxySettings />
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-section full-width">
|
<section class="settings-section full-width">
|
||||||
<h2>Application URL</h2>
|
<h2>Application URL</h2>
|
||||||
<p class="section-desc">
|
<p class="section-desc">
|
||||||
@@ -3660,7 +3562,6 @@ async function deleteUser(userId: number) {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.hidden-file-input { display: none; }
|
|
||||||
|
|
||||||
/* Checkboxes */
|
/* Checkboxes */
|
||||||
.checkbox-field { margin-bottom: 1rem; }
|
.checkbox-field { margin-bottom: 1rem; }
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "grimoire",
|
"name": "grimoire",
|
||||||
"description": "Grimoire for Claude Code: connects the grimoire MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-grimoire, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/grimoire:sync).",
|
"description": "Grimoire for Claude Code: connects the grimoire MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-grimoire, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/grimoire:sync).",
|
||||||
"version": "2026.10.21.2344",
|
"version": "2026.11.05.0301",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"grimoire": {
|
"grimoire": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,7 @@ another one means adding files, not moving or rewriting any.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **The skills** | `plugin/skills/*/SKILL.md` | Agent Skills (the open SKILL.md format). They state every Grimoire reflex in full and name no client. `tests/test_guidance_ownership.py` fails if a skill names a particular client, or references anything outside its own folder. Every client package ships this folder verbatim. |
|
| **The skills** | `plugin/skills/*/SKILL.md` | Agent Skills (the open SKILL.md format). They state every Grimoire reflex in full and name no client. `tests/test_guidance_ownership.py` fails if a skill names a particular client, or references anything outside its own folder. Every client package ships this folder verbatim. |
|
||||||
| **The MCP server** | `<base URL>/mcp` | HTTP, `Authorization: Bearer <fmcp_ key>`. Its `_INSTRUCTIONS` is a client-neutral orientation — the workflow across tools (≤1,600 chars); each tool's description carries its contract; in-band responses (`placement`, `report_back`, `systems_hint`, the duplicate gate, the guessed-id refusal) fire in every client. |
|
| **The MCP server** | `<base URL>/mcp` | HTTP, `Authorization: Bearer <fmcp_ key>`. Its `_INSTRUCTIONS` is a client-neutral orientation — the workflow across tools (≤1,600 chars); each tool's description carries its contract; in-band responses (`placement`, `report_back`, `systems_hint`, the duplicate gate, the guessed-id refusal) fire in every client. |
|
||||||
| **The adapter API** | `<base URL>/api/plugin/*` | Endpoints any client's hooks can call with the same key. `GET`, so read scope is enough: `context` (live session state), `retrieve` (rules, preferences and notes for a message), `prior-art` (records and shape-ledger hints for code being written), `tool-rules` (rules for a command about to run), `moment-tools` (which tools reach a mounted moment), `shape-check` (definitions written this turn that nobody has judged), `claim-session` / `release-session` (task claims), `processes` (stored Processes to expose as skills). `POST`, so write scope: `moment` (rules mounted on the moments a tool call reaches), `rule-opened` (a rule was read), `reply-rules` (the finished reply, held for an unopened rule or — when the turn closed a task — for missing completion sections). |
|
| **The adapter API** | `<base URL>/api/plugin/*` | Endpoints any client's hooks can call with the same key. `GET`, so read scope is enough: `context` (live session state), `retrieve` (rules, preferences and notes for a message), `prior-art` (records and shape-ledger hints for code being written), `tool-rules` (rules for a command about to run), `moment-tools` (which tools reach a mounted moment), `shape-check` (definitions written this turn that nobody has judged), `claim-session` / `release-session` (task claims), `write-scope` (which project a pending write lands in, and whether this session may make it), `processes` (stored Processes to expose as skills). `POST`, so write scope: `moment` (rules mounted on the moments a tool call reaches), `rule-opened` (a rule was read), `reply-rules` (the finished reply, held for an unopened rule or — when the turn closed a task — for missing completion sections). |
|
||||||
| **The API key** | Grimoire → Settings → MCP Access | One `fmcp_` key per install. Write scope covers the MCP tools and every hook; a read key runs only the `GET` hooks. |
|
| **The API key** | Grimoire → Settings → MCP Access | One `fmcp_` key per install. Write scope covers the MCP tools and every hook; a read key runs only the `GET` hooks. |
|
||||||
|
|
||||||
## Added by each client
|
## Added by each client
|
||||||
@@ -39,6 +39,7 @@ another one means adding files, not moving or rewriting any.
|
|||||||
| `hooks/grimoire_prior_art.sh` | PreToolUse on editor writes: `GET /api/plugin/prior-art`. |
|
| `hooks/grimoire_prior_art.sh` | PreToolUse on editor writes: `GET /api/plugin/prior-art`. |
|
||||||
| `hooks/grimoire_after_write.sh` | PostToolUse on shell commands: the same check for code written through the shell. |
|
| `hooks/grimoire_after_write.sh` | PostToolUse on shell commands: the same check for code written through the shell. |
|
||||||
| `hooks/grimoire_tool_rules.sh` | PreToolUse on shell commands: `GET /api/plugin/tool-rules`. |
|
| `hooks/grimoire_tool_rules.sh` | PreToolUse on shell commands: `GET /api/plugin/tool-rules`. |
|
||||||
|
| `hooks/grimoire_project_fence.sh` | PreToolUse on editor writes, shell commands and Grimoire's tools: `GET /api/plugin/write-scope` for an act aimed at another project, answered `ask` with the reason; PostToolUse on `enter_project` remembers the project a session without one entered. |
|
||||||
| `hooks/grimoire_moment.sh` | PreToolUse on every tool: the rules mounted on the moments the call reaches, `POST /api/plugin/moment`; skips tools `GET /api/plugin/moment-tools` says reach nothing mounted, and Grimoire's own (their responses carry `moment_rules`). |
|
| `hooks/grimoire_moment.sh` | PreToolUse on every tool: the rules mounted on the moments the call reaches, `POST /api/plugin/moment`; skips tools `GET /api/plugin/moment-tools` says reach nothing mounted, and Grimoire's own (their responses carry `moment_rules`). |
|
||||||
| `hooks/grimoire_reply_check.sh` | Stop: sends the finished reply to `POST /api/plugin/reply-rules` — the rules mounted on the reply moments, and the reply against every rule's trigger, and — when the turn closed a task — the completion-section check; holds once per rule, with the reason the server returns, and never holds the rewrite. |
|
| `hooks/grimoire_reply_check.sh` | Stop: sends the finished reply to `POST /api/plugin/reply-rules` — the rules mounted on the reply moments, and the reply against every rule's trigger, and — when the turn closed a task — the completion-section check; holds once per rule, with the reason the server returns, and never holds the rewrite. |
|
||||||
| `hooks/grimoire_shape_check.sh` | Stop: sends the definitions the turn wrote (the write hooks' `<sid>.written.ids` ledger) to `GET /api/plugin/shape-check`; blocks once, with the reason the server returns, so the agent judges what it built. |
|
| `hooks/grimoire_shape_check.sh` | Stop: sends the definitions the turn wrote (the write hooks' `<sid>.written.ids` ledger) to `GET /api/plugin/shape-check`; blocks once, with the reason the server returns, so the agent judges what it built. |
|
||||||
|
|||||||
+16
-2
@@ -1,6 +1,6 @@
|
|||||||
# Grimoire plugin for Claude Code
|
# Grimoire plugin for Claude Code
|
||||||
|
|
||||||
Turns a self-hosted [Grimoire](https://git.fabledsword.com/bvandeusen/FabledScribe)
|
Turns a self-hosted [Grimoire](https://git.fabledsword.com/bvandeusen/FabledGrimoire)
|
||||||
instance into a first-class Claude Code extension:
|
instance into a first-class Claude Code extension:
|
||||||
|
|
||||||
- **MCP tools** over your notes, tasks, projects, milestones, systems, and
|
- **MCP tools** over your notes, tasks, projects, milestones, systems, and
|
||||||
@@ -41,7 +41,7 @@ The plugin ships inside the Grimoire app repo, so the marketplace *is* that repo
|
|||||||
you always get the plugin version that matches your Grimoire instance.
|
you always get the plugin version that matches your Grimoire instance.
|
||||||
|
|
||||||
```
|
```
|
||||||
/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledScribe.git
|
/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledGrimoire.git
|
||||||
/plugin install grimoire@grimoire-plugin
|
/plugin install grimoire@grimoire-plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -79,6 +79,20 @@ On install you'll be asked for:
|
|||||||
message, as a short menu of titles and matched passages.
|
message, as a short menu of titles and matched passages.
|
||||||
- `hooks/hooks.json` → PreToolUse hook on `Bash` (`hooks/grimoire_tool_rules.sh`)
|
- `hooks/hooks.json` → PreToolUse hook on `Bash` (`hooks/grimoire_tool_rules.sh`)
|
||||||
→ `GET /api/plugin/tool-rules`: the rules that match the command about to run.
|
→ `GET /api/plugin/tool-rules`: the rules that match the command about to run.
|
||||||
|
- `hooks/hooks.json` → PreToolUse hook on editor writes, `Bash` and Grimoire's
|
||||||
|
tools (`hooks/grimoire_project_fence.sh`) → `GET /api/plugin/write-scope`:
|
||||||
|
holds a session to its own project. A session belongs to the project its
|
||||||
|
starting directory resolves to (a `.grimoire` marker, else the git remote's
|
||||||
|
binding), or, started outside any, to the first project it enters. Editing
|
||||||
|
another project's files, a git commit, push, merge, rebase, pull, tag or
|
||||||
|
reset in its repo (from `git -C`, after `cd`, or in a worktree of it), and a
|
||||||
|
Grimoire write that lands in another project each stop on a permission
|
||||||
|
prompt naming that project, in every permission mode. Filing an issue there
|
||||||
|
(`create_task(kind="issue")`) and every read go ahead. An edit in the
|
||||||
|
session's own repo never calls the server. It can't see a non-git shell
|
||||||
|
write into another repo (`sed -i`, a redirect), but it fences that repo's
|
||||||
|
commit and push. When the instance doesn't answer, the act goes ahead and
|
||||||
|
the hook says so once per outage.
|
||||||
- `hooks/hooks.json` → PreToolUse hook on every tool (`hooks/grimoire_moment.sh`)
|
- `hooks/hooks.json` → PreToolUse hook on every tool (`hooks/grimoire_moment.sh`)
|
||||||
→ `POST /api/plugin/moment`: the rules mounted on the moments the call
|
→ `POST /api/plugin/moment`: the rules mounted on the moments the call
|
||||||
reaches. It skips tools that `GET /api/plugin/moment-tools` says reach
|
reaches. It skips tools that `GET /api/plugin/moment-tools` says reach
|
||||||
|
|||||||
@@ -119,10 +119,11 @@ combined=""
|
|||||||
n_files=0
|
n_files=0
|
||||||
while IFS= read -r rel_path; do
|
while IFS= read -r rel_path; do
|
||||||
[ -n "${rel_path:-}" ] || continue
|
[ -n "${rel_path:-}" ] || continue
|
||||||
# A Bash call that rewrote many files is a refactor or a generator, not a
|
# Every changed file goes into the end-of-turn ledger, up to forty — a bound
|
||||||
# shape being instantiated; four is enough to name what matters.
|
# on this hook's run time, past which the call was a generator. The arms
|
||||||
|
# below speak for the first four only (see the check after the ledger).
|
||||||
n_files=$((n_files + 1))
|
n_files=$((n_files + 1))
|
||||||
[ "$n_files" -le 4 ] || break
|
[ "$n_files" -le 40 ] || break
|
||||||
file_path="$repo_root/$rel_path"
|
file_path="$repo_root/$rel_path"
|
||||||
|
|
||||||
# The code just written: the ADDED lines of the uncommitted diff for a
|
# The code just written: the ADDED lines of the uncommitted diff for a
|
||||||
@@ -136,14 +137,19 @@ while IFS= read -r rel_path; do
|
|||||||
fresh="new"
|
fresh="new"
|
||||||
fi
|
fi
|
||||||
[ -n "$code" ] || continue
|
[ -n "$code" ] || continue
|
||||||
# `|| true` inside: an early-exiting `head` must not void its own output.
|
all_names=$(printf '%s' "$code" | grimoire_defs | sort -u || true)
|
||||||
names=$(printf '%s' "$code" | grimoire_defs | sort -u | head -12 || true)
|
# What this write defined, for the end-of-turn question: every definition,
|
||||||
# What this write defined, for the end-of-turn question —
|
|
||||||
# recorded before the arms below, which skip a write that defines nothing.
|
# recorded before the arms below, which skip a write that defines nothing.
|
||||||
printf '%s\n' "$names" | grimoire_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
|
printf '%s\n' "$all_names" | grimoire_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
|
||||||
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
|
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
|
||||||
# nothing to say; the arms are about shapes.
|
# nothing to say; the arms are about shapes.
|
||||||
[ -n "$names" ] || continue
|
[ -n "$all_names" ] || continue
|
||||||
|
# A Bash call that rewrote many files is a refactor or a generator, not a
|
||||||
|
# shape being instantiated; four files and twelve names per file are enough
|
||||||
|
# for the arms to name what matters, and bound their git grep and URL.
|
||||||
|
[ "$n_files" -le 4 ] || continue
|
||||||
|
# `|| true` inside: an early-exiting `head` must not void its own output.
|
||||||
|
names=$(printf '%s\n' "$all_names" | head -12 || true)
|
||||||
|
|
||||||
local_lines=$(grimoire_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
|
local_lines=$(grimoire_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
|
||||||
local_context=""
|
local_context=""
|
||||||
|
|||||||
@@ -1308,7 +1308,7 @@ grimoire_turn_fact() {
|
|||||||
# Nothing Grimoire should be holding goes in it.
|
# Nothing Grimoire should be holding goes in it.
|
||||||
#
|
#
|
||||||
# {"instance": "https://grimoire.example.com", "project_id": 2,
|
# {"instance": "https://grimoire.example.com", "project_id": 2,
|
||||||
# "project": "FabledScribe"}
|
# "project": "FabledGrimoire"}
|
||||||
#
|
#
|
||||||
# instance WHICH Grimoire the id belongs to, and the reason this file is
|
# instance WHICH Grimoire the id belongs to, and the reason this file is
|
||||||
# not just a number. A project id means nothing on its own: id 2
|
# not just a number. A project id means nothing on its own: id 2
|
||||||
|
|||||||
@@ -79,10 +79,16 @@ fi
|
|||||||
|
|
||||||
# ARM 1 — BY NAME, LOCALLY: does a definition of this already exist
|
# ARM 1 — BY NAME, LOCALLY: does a definition of this already exist
|
||||||
# in the repo? (grimoire_local_dups in grimoire_defs.sh carries the why.)
|
# in the repo? (grimoire_local_dups in grimoire_defs.sh carries the why.)
|
||||||
|
#
|
||||||
|
# `all_names` is every definition the write carries; it feeds the end-of-turn
|
||||||
|
# ledger, which must hear about all of them. `names` is the first twelve, and
|
||||||
|
# bounds the queries below — a git grep and a URL per name.
|
||||||
|
all_names=""
|
||||||
names=""
|
names=""
|
||||||
if [ -n "$code" ]; then
|
if [ -n "$code" ]; then
|
||||||
|
all_names=$(printf '%s' "$code" | grimoire_defs | sort -u || true)
|
||||||
# `|| true` inside: an early-exiting `head` must not void its own output.
|
# `|| true` inside: an early-exiting `head` must not void its own output.
|
||||||
names=$(printf '%s' "$code" | grimoire_defs | sort -u | head -12 || true)
|
names=$(printf '%s\n' "$all_names" | head -12 || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local_lines=""
|
local_lines=""
|
||||||
@@ -204,10 +210,11 @@ if [ -n "$session_id" ]; then
|
|||||||
# Write onto a path that does not exist yet creates a file, which may be a
|
# Write onto a path that does not exist yet creates a file, which may be a
|
||||||
# unit in its own right (see grimoire_written_append). Written before any
|
# unit in its own right (see grimoire_written_append). Written before any
|
||||||
# server call: the ask at the end of the turn must not depend on this
|
# server call: the ask at the end of the turn must not depend on this
|
||||||
# write's hint having been answered.
|
# write's hint having been answered. Every definition, not the capped list
|
||||||
|
# the queries use; an Edit that names none records its enclosing one.
|
||||||
fresh=""
|
fresh=""
|
||||||
[ -e "$file_path" ] || fresh="new"
|
[ -e "$file_path" ] || fresh="new"
|
||||||
printf '%s\n' "$shapes" | grimoire_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
|
printf '%s\n' "${all_names:-$shapes}" | grimoire_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
|
||||||
idfile="$state_dir/${safe_sid}.ids"
|
idfile="$state_dir/${safe_sid}.ids"
|
||||||
syncfile="$state_dir/${safe_sid}.sync.ids"
|
syncfile="$state_dir/${safe_sid}.sync.ids"
|
||||||
derivefile="$state_dir/${safe_sid}.derive.ids"
|
derivefile="$state_dir/${safe_sid}.derive.ids"
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Grimoire — hold a session to its own project.
|
||||||
|
#
|
||||||
|
# A session belongs to the project its starting directory resolves to
|
||||||
|
# (CLAUDE_PROJECT_DIR: a `.grimoire` marker, else the git remote's binding). A
|
||||||
|
# session that starts outside any project belongs to the first project it
|
||||||
|
# enters. Toward any other project a session may read anything and file an
|
||||||
|
# issue. Every other write stops on a permission prompt naming the other
|
||||||
|
# project, so the operator decides:
|
||||||
|
# - editing a file in another project's repo (Write, Edit, MultiEdit,
|
||||||
|
# NotebookEdit);
|
||||||
|
# - a git command that changes history or a remote there (commit, push,
|
||||||
|
# merge, rebase, pull, cherry-pick, revert, tag, am, reset, worktree add),
|
||||||
|
# wherever it runs from: `git -C DIR`, `cd DIR && git …`, or the shell's
|
||||||
|
# current directory;
|
||||||
|
# - a Grimoire write that lands in another project.
|
||||||
|
#
|
||||||
|
# `ask`, not `deny`. Which projects a session may change is the operator's
|
||||||
|
# call, and `ask` puts it to them each time, in every permission mode,
|
||||||
|
# bypassPermissions included.
|
||||||
|
#
|
||||||
|
# The server says where an act lands (GET /api/plugin/write-scope). Two cases
|
||||||
|
# never reach it:
|
||||||
|
# - a directory that resolves the same way as the session's own, which is
|
||||||
|
# nearly every edit;
|
||||||
|
# - a tool named like a read (READ_PREFIXES in services/write_scope.py, held
|
||||||
|
# equal to FENCE_READ_PREFIXES below by tests/test_write_scope.py).
|
||||||
|
#
|
||||||
|
# What it can't see: a non-git write from the shell (`sed -i`, a redirect)
|
||||||
|
# into another repo. That repo's commit and push are still fenced.
|
||||||
|
#
|
||||||
|
# EXIT 0, ALWAYS. When the server doesn't answer, the act goes ahead and the
|
||||||
|
# hook says once per outage that it couldn't check.
|
||||||
|
#
|
||||||
|
# Run as PostToolUse on enter_project, it records the project a session
|
||||||
|
# without one has entered, and says nothing.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=plugin/hooks/grimoire_defs.sh
|
||||||
|
. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh"
|
||||||
|
|
||||||
|
FENCE_READ_PREFIXES="get_ list_ search find_ enter_project what_might_apply resolve_design_system"
|
||||||
|
FENCE_GIT_WRITES="commit push merge rebase pull cherry-pick revert tag am reset"
|
||||||
|
|
||||||
|
event=$(cat 2>/dev/null || true)
|
||||||
|
[ -n "$event" ] || exit 0
|
||||||
|
event_flat=$(printf '%s' "$event" | grimoire_json_flat)
|
||||||
|
[ -n "$event_flat" ] || exit 0
|
||||||
|
|
||||||
|
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
|
||||||
|
tool_name=$(grimoire_json_pick "$event_flat" '.tool_name')
|
||||||
|
hook_event=$(grimoire_json_pick "$event_flat" '.hook_event_name')
|
||||||
|
cwd=$(grimoire_json_pick "$event_flat" '.cwd')
|
||||||
|
# Its own directory, which no compaction sweeps: the project a session
|
||||||
|
# entered stays its project for the whole session.
|
||||||
|
state_dir="${TMPDIR:-/tmp}/grimoire-fence"
|
||||||
|
safe_sid=$(printf '%s' "${session_id:-none}" | tr -c 'A-Za-z0-9._-' '_')
|
||||||
|
entered_file="$state_dir/${safe_sid}.entered"
|
||||||
|
|
||||||
|
# ── PostToolUse on enter_project: remember the first project entered ─────
|
||||||
|
if [ "$hook_event" = "PostToolUse" ]; then
|
||||||
|
case "$tool_name" in
|
||||||
|
*__enter_project)
|
||||||
|
pid=$(grimoire_json_pick "$event_flat" '.tool_input.project_id' | tr -cd '0-9')
|
||||||
|
if [ -n "$pid" ] && [ -n "$session_id" ] && [ ! -s "$entered_file" ]; then
|
||||||
|
mkdir -p "$state_dir" 2>/dev/null || true
|
||||||
|
printf '%s' "$pid" > "$entered_file" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The nearest directory at or above $1 that exists. A Write may create the
|
||||||
|
# directories its file goes in.
|
||||||
|
fence_existing_dir() {
|
||||||
|
local d="$1" depth=0
|
||||||
|
while [ -n "$d" ] && [ ! -d "$d" ] && [ "$depth" -lt 40 ]; do
|
||||||
|
d=$(dirname -- "$d" 2>/dev/null) || return 0
|
||||||
|
depth=$((depth + 1))
|
||||||
|
done
|
||||||
|
[ -d "$d" ] && printf '%s' "$d"
|
||||||
|
}
|
||||||
|
|
||||||
|
# "DIR<TAB>SUBCOMMAND" for each git command in the shell command $1 that
|
||||||
|
# changes history or a remote, with DIR resolved from $2: `cd` moves it for
|
||||||
|
# the segments after, `git -C` for that one command. Segments split on ; && ||
|
||||||
|
# | and newlines, and a quoted separator inside a message splits too; the
|
||||||
|
# words after it are read as a command only if they start with cd or git.
|
||||||
|
fence_git_targets() {
|
||||||
|
printf '%s\n' "$1" | awk -v start="$2" -v home="${HOME:-}" -v writes="$FENCE_GIT_WRITES" '
|
||||||
|
function unq(w) { gsub(/^["\047(]+|["\047)]+$/, "", w); return w }
|
||||||
|
function at(base, p) {
|
||||||
|
if (p ~ /^\//) return p
|
||||||
|
if (p == "~") return home
|
||||||
|
if (p ~ /^~\//) return home substr(p, 2)
|
||||||
|
return base "/" p
|
||||||
|
}
|
||||||
|
BEGIN { dir = start; n = split(writes, w, " "); for (i = 1; i <= n; i++) iswrite[w[i]] = 1 }
|
||||||
|
{
|
||||||
|
line = $0
|
||||||
|
gsub(/&&|\|\||;|\|/, "\n", line)
|
||||||
|
m = split(line, segs, "\n")
|
||||||
|
for (s = 1; s <= m; s++) {
|
||||||
|
k = split(segs[s], t, /[ \t]+/)
|
||||||
|
i = 1
|
||||||
|
while (i <= k && (t[i] == "" || t[i] ~ /^[A-Za-z_][A-Za-z0-9_]*=/)) i++
|
||||||
|
if (i > k) continue
|
||||||
|
cmd = unq(t[i])
|
||||||
|
if (cmd == "cd") {
|
||||||
|
if (i + 1 <= k && t[i + 1] != "") dir = at(dir, unq(t[i + 1]))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (cmd != "git" && cmd !~ /\/git$/) continue
|
||||||
|
gdir = dir; i++
|
||||||
|
while (i <= k && t[i] ~ /^-/) {
|
||||||
|
if (t[i] == "-C" && i + 1 <= k) { gdir = at(gdir, unq(t[i + 1])); i += 2; continue }
|
||||||
|
if (t[i] == "-c" || t[i] == "--git-dir" || t[i] == "--work-tree") { i += 2; continue }
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (i > k) continue
|
||||||
|
sub_ = unq(t[i])
|
||||||
|
if (sub_ == "worktree") { if (i + 1 <= k && unq(t[i + 1]) == "add") print gdir "\tworktree add"; continue }
|
||||||
|
if (sub_ in iswrite) print gdir "\t" sub_
|
||||||
|
}
|
||||||
|
}' 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# The short scalar arguments of the MCP call, as a JSON object of strings:
|
||||||
|
# ids, kind, status, a repo url, and arrays of ids. Long text (bodies, code) is
|
||||||
|
# left out; nothing that says where a write lands is long.
|
||||||
|
fence_tool_input() {
|
||||||
|
printf '%s\n' "$event_flat" | awk -F'\t' '
|
||||||
|
function add(k, v) { if (!(k in seen)) { seen[k] = 1; order[++n] = k } }
|
||||||
|
$2 ~ /^\.tool_input\.[A-Za-z_]+$/ && $3 != "null" && length($3) <= 200 {
|
||||||
|
k = substr($2, 13); add(k); val[k] = "\"" $3 "\""
|
||||||
|
}
|
||||||
|
$2 ~ /^\.tool_input\.[A-Za-z_]+\[[0-9]+\]$/ && $3 != "null" && length($3) <= 200 {
|
||||||
|
k = substr($2, 13); sub(/\[.*$/, "", k); add(k)
|
||||||
|
# Tested before assigning: mawk creates arr[k] as soon as it is the
|
||||||
|
# target, so the test inside the assignment would always be true.
|
||||||
|
item = "\"" $3 "\""
|
||||||
|
if (k in arr) item = arr[k] "," item
|
||||||
|
arr[k] = item
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
printf "{"
|
||||||
|
for (i = 1; i <= n; i++) {
|
||||||
|
k = order[i]
|
||||||
|
printf "%s\"%s\":%s", (i > 1 ? "," : ""), k, ((k in arr) ? "[" arr[k] "]" : val[k])
|
||||||
|
}
|
||||||
|
printf "}"
|
||||||
|
}' 2>/dev/null || printf '{}'
|
||||||
|
}
|
||||||
|
|
||||||
|
grimoire_config || exit 0
|
||||||
|
command -v curl >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
|
project_dir=${CLAUDE_PROJECT_DIR:-$PWD}
|
||||||
|
session_scope=$(grimoire_scope_query "$project_dir")
|
||||||
|
entered=$(cat "$entered_file" 2>/dev/null | tr -cd '0-9')
|
||||||
|
# No project to hold the session to: started outside any, and entered none.
|
||||||
|
[ -n "$session_scope" ] || [ -n "$entered" ] || exit 0
|
||||||
|
|
||||||
|
# Each check this act needs: "SCOPE<TAB>ACT" for a directory, or a single
|
||||||
|
# "tool" line for a Grimoire call.
|
||||||
|
checks=""
|
||||||
|
case "$tool_name" in
|
||||||
|
Write|Edit|MultiEdit|NotebookEdit)
|
||||||
|
path=$(grimoire_json_pick "$event_flat" '.tool_input.file_path')
|
||||||
|
[ -n "$path" ] || path=$(grimoire_json_pick "$event_flat" '.tool_input.notebook_path')
|
||||||
|
[ -n "$path" ] || exit 0
|
||||||
|
case "$path" in /*) ;; *) path="${cwd:-$PWD}/$path" ;; esac
|
||||||
|
dir=$(fence_existing_dir "$(dirname -- "$path")")
|
||||||
|
[ -n "$dir" ] || exit 0
|
||||||
|
scope=$(grimoire_scope_query "$dir")
|
||||||
|
[ -n "$scope" ] && [ "$scope" != "$session_scope" ] || exit 0
|
||||||
|
checks=$(printf '%s\t%s' "$scope" "Editing a file in")
|
||||||
|
;;
|
||||||
|
Bash)
|
||||||
|
command=$(grimoire_json_pick "$event_flat" '.tool_input.command')
|
||||||
|
case "$command" in *git*) ;; *) exit 0 ;; esac
|
||||||
|
while IFS=$'\t' read -r gdir gsub; do
|
||||||
|
[ -n "$gdir" ] || continue
|
||||||
|
[ -d "$gdir" ] || continue
|
||||||
|
scope=$(grimoire_scope_query "$gdir")
|
||||||
|
[ -n "$scope" ] && [ "$scope" != "$session_scope" ] || continue
|
||||||
|
checks="${checks}${checks:+$'\n'}${scope}"$'\t'"git ${gsub} in"
|
||||||
|
done <<EOF
|
||||||
|
$(fence_git_targets "$command" "${cwd:-$PWD}")
|
||||||
|
EOF
|
||||||
|
[ -n "$checks" ] || exit 0
|
||||||
|
;;
|
||||||
|
mcp__*)
|
||||||
|
short=${tool_name##*__}
|
||||||
|
for prefix in $FENCE_READ_PREFIXES; do
|
||||||
|
case "$short" in "$prefix"*) exit 0 ;; esac
|
||||||
|
done
|
||||||
|
checks="tool"
|
||||||
|
;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
base="${url%/}/api/plugin/write-scope?"
|
||||||
|
[ -n "$session_scope" ] && base="${base}${session_scope}&"
|
||||||
|
[ -n "$entered" ] && base="${base}fallback_project_id=${entered}&"
|
||||||
|
|
||||||
|
fence_unreached() {
|
||||||
|
local marker="$state_dir/${safe_sid}.fence.unreached" now last
|
||||||
|
mkdir -p "$state_dir" 2>/dev/null || true
|
||||||
|
now=$(date +%s 2>/dev/null) || now=0
|
||||||
|
if [ -f "$marker" ]; then
|
||||||
|
last=$(cat "$marker" 2>/dev/null) || last=0
|
||||||
|
case "$last" in ''|*[!0-9]*) last=0 ;; esac
|
||||||
|
[ $((now - last)) -lt 600 ] && return 0
|
||||||
|
fi
|
||||||
|
printf '%s' "$now" > "$marker" 2>/dev/null || true
|
||||||
|
grimoire_json_out "PreToolUse" "> Grimoire did not answer the project fence check, so this act went ahead without being checked against the session's project. If it changes another project's files or records, stop and file an issue there instead. Said once per outage."
|
||||||
|
}
|
||||||
|
|
||||||
|
while IFS= read -r check; do
|
||||||
|
[ -n "$check" ] || continue
|
||||||
|
if [ "$check" = "tool" ]; then
|
||||||
|
input=$(fence_tool_input | grimoire_urlenc)
|
||||||
|
q="tool=$(printf '%s' "$short" | grimoire_urlenc)&input=${input}"
|
||||||
|
else
|
||||||
|
scope=${check%%$'\t'*}
|
||||||
|
act=${check#*$'\t'}
|
||||||
|
q="target_${scope}&act=$(printf '%s' "$act" | grimoire_urlenc)"
|
||||||
|
fi
|
||||||
|
resp=$(curl -fsS --max-time 4 -H "Authorization: Bearer ${token}" "${base}${q}" 2>/dev/null) || {
|
||||||
|
fence_unreached
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
rm -f "$state_dir/${safe_sid}.fence.unreached" 2>/dev/null || true
|
||||||
|
flat=$(printf '%s' "$resp" | grimoire_json_flat)
|
||||||
|
if [ "$(grimoire_json_pick "$flat" '.allowed')" = "false" ]; then
|
||||||
|
reason=$(grimoire_json_pick "$flat" '.reason')
|
||||||
|
esc=$(printf '%s' "$reason" | grimoire_json_escape) || exit 0
|
||||||
|
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' "$esc"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done <<EOF
|
||||||
|
$checks
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
@@ -72,19 +72,29 @@ printf '%s\n' "$rule_id" | grimoire_rules_append "$state_dir/${safe_sid}.opened.
|
|||||||
|
|
||||||
# ── What came just before the open ───────────────────────────────────────
|
# ── What came just before the open ───────────────────────────────────────
|
||||||
#
|
#
|
||||||
# grimoire_moment.sh writes every call to `grimoire-moment/<sid>.acts`. The ones
|
# grimoire_moment.sh writes every call to `grimoire-moment/<sid>.acts`. The
|
||||||
# from the last three minutes go to the server with this rule's id: a rule
|
# last three, from the last ninety seconds, go to the server with this rule's
|
||||||
# opened just after a moment fired is evidence it belongs on that moment, and
|
# id: a rule the agent went and opened right after a moment fired is evidence
|
||||||
# once that repeats across sessions the server answers with one line asking
|
# it belongs on that moment, and once that repeats across sessions the server
|
||||||
# the reader to offer the operator the mount. Nothing is mounted by this.
|
# answers with one line putting the question to the reader. Nothing is
|
||||||
|
# mounted by this.
|
||||||
|
#
|
||||||
|
# An open of a rule a hook has just NAMED is not reported. A ranked arm or a
|
||||||
|
# moment line put it in front of the reader, and that explains the open; the
|
||||||
|
# moment beside it did not, so counting it would propose mounts on whatever
|
||||||
|
# moment happened to fire while search was surfacing the rule. The window is
|
||||||
|
# short for the same reason: a few minutes of agent work reaches many moments.
|
||||||
command -v curl >/dev/null 2>&1 || exit 0
|
command -v curl >/dev/null 2>&1 || exit 0
|
||||||
grimoire_config || exit 0
|
grimoire_config || exit 0
|
||||||
|
case ",$(grimoire_rules_live "$state_dir/${safe_sid}.rules.ids")," in
|
||||||
|
*",${rule_id},"*) exit 0 ;;
|
||||||
|
esac
|
||||||
acts_file="${TMPDIR:-/tmp}/grimoire-moment/${safe_sid}.acts"
|
acts_file="${TMPDIR:-/tmp}/grimoire-moment/${safe_sid}.acts"
|
||||||
[ -f "$acts_file" ] || exit 0
|
[ -f "$acts_file" ] || exit 0
|
||||||
now=$(date +%s 2>/dev/null) || exit 0
|
now=$(date +%s 2>/dev/null) || exit 0
|
||||||
acts=$(awk -F '\t' -v since=$((now - 180)) \
|
acts=$(awk -F '\t' -v since=$((now - 90)) \
|
||||||
'$1 + 0 >= since { sub(/^[^\t]*\t/, ""); print }' "$acts_file" 2>/dev/null \
|
'$1 + 0 >= since { sub(/^[^\t]*\t/, ""); print }' "$acts_file" 2>/dev/null \
|
||||||
| tail -n 30 | paste -sd ',' - 2>/dev/null)
|
| tail -n 3 | paste -sd ',' - 2>/dev/null)
|
||||||
[ -n "$acts" ] || exit 0
|
[ -n "$acts" ] || exit 0
|
||||||
|
|
||||||
sid_esc=$(printf '%s' "$session_id" | grimoire_json_escape) || exit 0
|
sid_esc=$(printf '%s' "$session_id" | grimoire_json_escape) || exit 0
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ event_flat=$(printf '%s' "$event" | grimoire_json_flat)
|
|||||||
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
|
session_id=$(grimoire_json_pick "$event_flat" '.session_id')
|
||||||
[ -n "$session_id" ] || exit 0
|
[ -n "$session_id" ] || exit 0
|
||||||
|
|
||||||
# The moment arm's per-session files (its tool cache and the acts ledger)
|
# The moment arm's per-session files (its tool cache and the acts ledger) and
|
||||||
# describe a session that is over, on any exit — a clear
|
# the fence's (the project the session entered, its outage marker) describe a
|
||||||
# included, since the next conversation is a new session id.
|
# session that is over, on any exit — a clear included, since the next
|
||||||
|
# conversation is a new session id.
|
||||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||||
rm -f "${TMPDIR:-/tmp}/grimoire-moment/${safe_sid}".* 2>/dev/null || true
|
rm -f "${TMPDIR:-/tmp}/grimoire-moment/${safe_sid}".* \
|
||||||
|
"${TMPDIR:-/tmp}/grimoire-fence/${safe_sid}".* 2>/dev/null || true
|
||||||
|
|
||||||
reason=$(grimoire_json_pick "$event_flat" '.reason')
|
reason=$(grimoire_json_pick "$event_flat" '.reason')
|
||||||
[ "$reason" = "clear" ] && exit 0
|
[ "$reason" = "clear" ] && exit 0
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*grimoire.*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_project_fence.sh\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matcher": "Write|Edit",
|
"matcher": "Write|Edit",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
@@ -89,6 +98,15 @@
|
|||||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_claim_session.sh\""
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_claim_session.sh\""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "mcp__.*__enter_project",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/grimoire_project_fence.sh\""
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PreCompact": [
|
"PreCompact": [
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ row carries a status:
|
|||||||
nothing. Rows, never prose — a consumer list in a note or verification
|
nothing. Rows, never prose — a consumer list in a note or verification
|
||||||
detail cannot be sorted, queried, or diffed.
|
detail cannot be sorted, queried, or diffed.
|
||||||
|
|
||||||
|
Rows are keyed by repo and path, so a move in the tree is a move in the
|
||||||
|
ledger. When a file or directory moves, run `move_path(project_id,
|
||||||
|
from_path, to_path)` before the next refresh; it carries the snippets
|
||||||
|
recorded there too. For a repo renamed on its
|
||||||
|
forge, run `bind_repo(new_url, project_id, moved_from=old_url)`. Otherwise
|
||||||
|
the refresh marks every judged row vanished and lists the same shapes again,
|
||||||
|
unjudged.
|
||||||
|
|
||||||
## The writer judges; audits check that it happened
|
## The writer judges; audits check that it happened
|
||||||
|
|
||||||
The ledger is filled from your answers, given while you still know them. When
|
The ledger is filled from your answers, given while you still know them. When
|
||||||
@@ -157,8 +165,8 @@ Three questions the ledger answers mechanically:
|
|||||||
it), `variant` with the why if the departure is deliberate. The write-path
|
it), `variant` with the why if the departure is deliberate. The write-path
|
||||||
hint asks the same question in-band the moment such a shape is written.
|
hint asks the same question in-band the moment such a shape is written.
|
||||||
- **History** — `shape_history(project_id, path, symbol?)`: the current rows
|
- **History** — `shape_history(project_id, path, symbol?)`: the current rows
|
||||||
plus every `classified` / `vanished` / `reappeared` / `drifted` event with
|
plus every `classified` / `vanished` / `reappeared` / `drifted` / `moved`
|
||||||
its commit — "instance of #N from <date>, re-judged variant of #M because
|
event with its commit — "instance of #N from <date>, re-judged variant of #M because
|
||||||
R, vanished at C". Rows, not recollection.
|
R, vanished at C". Rows, not recollection.
|
||||||
- **Recheck** — `list_shapes(project_id, flag="recheck")`: judged
|
- **Recheck** — `list_shapes(project_id, flag="recheck")`: judged
|
||||||
instances/variants whose body changed since judged. The judgment stands;
|
instances/variants whose body changed since judged. The judgment stands;
|
||||||
|
|||||||
@@ -277,11 +277,12 @@ bound — confine the session to it:
|
|||||||
- **Pass that `project_id` to every read** (`search`, `list_tasks`,
|
- **Pass that `project_id` to every read** (`search`, `list_tasks`,
|
||||||
`list_notes`). An unscoped read bleeds every other project's work into your
|
`list_notes`). An unscoped read bleeds every other project's work into your
|
||||||
context.
|
context.
|
||||||
- **Only reference or offer work on the in-scope project.** Don't surface,
|
- **Only reference or offer work on the in-scope project**, unless the
|
||||||
suggest, or start work on other projects unless the operator explicitly widens
|
operator explicitly widens scope; work that belongs elsewhere, say so and ask.
|
||||||
scope.
|
- **Cross-project, the one change to make is filing an issue**:
|
||||||
- If something clearly belongs to a *different* project, say so and **ask before
|
`create_task(project_id=<theirs>, kind="issue")` with how you found it and the
|
||||||
switching** — never silently operate cross-project.
|
evidence. Reading there is fine; editing its files, committing or pushing in
|
||||||
|
its repo, or changing its records is the operator's call, and the plugin asks.
|
||||||
|
|
||||||
## Read these when the moment comes
|
## Read these when the moment comes
|
||||||
|
|
||||||
|
|||||||
@@ -73,12 +73,16 @@ than a mount is fixed through its trigger instead.
|
|||||||
|
|
||||||
## When a line proposes a mount
|
## When a line proposes a mount
|
||||||
|
|
||||||
A rule that keeps being opened just after the same moment, across several
|
A rule that sessions keep going and opening just after the same moment —
|
||||||
sessions, probably belongs on that moment. A line says so, naming the rule,
|
without a hook having shown it to them first — may belong on that moment. A
|
||||||
the moment and how often. It is a question for the operator, not a change you
|
line says so, naming the rule, the moment, the action that reached it and how
|
||||||
make: offer it in one line, and record their answer with
|
often. That is co-occurrence, not a recommendation, so judge it before it
|
||||||
`judge_rule_moments` — `confirm` mounts the rule, `reject` with their reason
|
reaches the operator: read the rule's trigger against what the moment means
|
||||||
stops the question being asked again.
|
(`list_moments`). Offer it only when the rule governs that point in the work
|
||||||
|
whatever the work is about, as a question with your reason; a rule about a
|
||||||
|
subject (a library, a file, a style) is reached by meaning, and the line is
|
||||||
|
left alone. Record their answer with `judge_rule_moments` — `confirm` mounts
|
||||||
|
the rule, `reject` with their reason stops the question being asked again.
|
||||||
|
|
||||||
The same tool answers proposals from a pass over the rules
|
The same tool answers proposals from a pass over the rules
|
||||||
(`rules_to_mount`, `propose_rule_moments`, `rule_moment_proposals`): a pass
|
(`rules_to_mount`, `propose_rule_moments`, `rule_moment_proposals`): a pass
|
||||||
|
|||||||
+4
-2
@@ -1,11 +1,13 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
|
# 77 is the first setuptools that reads `license` as an SPDX expression.
|
||||||
|
requires = ["setuptools>=77.0", "setuptools-scm>=8.0"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "grimoire"
|
name = "grimoire"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Self-hosted note-taking, project-management, and calendar app exposed to Claude via MCP"
|
description = "A self-hosted system of record for the work you do with Claude Code"
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quart>=0.19",
|
"quart>=0.19",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# requires-python = ">=3.10"
|
# requires-python = ">=3.10"
|
||||||
# dependencies = ["httpx>=0.27"]
|
# dependencies = ["httpx>=0.27"]
|
||||||
# ///
|
# ///
|
||||||
"""Benchmark Ollama models for FabledScribe chat / curator workloads.
|
"""Benchmark Ollama models for FabledGrimoire chat / curator workloads.
|
||||||
|
|
||||||
Measures time-to-first-token, total wall time, and tokens/sec for each
|
Measures time-to-first-token, total wall time, and tokens/sec for each
|
||||||
(model, scenario) pair. Designed to be runnable against both local and
|
(model, scenario) pair. Designed to be runnable against both local and
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user