Chishiki, a digital compendium as one single php file web app

Time for a new instance in my series "a simple web app as a single php file" (check for others here). This time I've made one to create digital compendiums: compilation of information organised as keywords referring to text and/or image, available as a single php file web app. Whether you want to keep track of your favourite coffee beans, are fed up to crawl the web garbages to find once again how to regex an email address, or simply believe in the dead internet theory and want to make your own secured source of whatever knowledge you need before it disappears, this app may interest you. Paper books are even better, sure, I do have a big pile on my desk. But the power of search and editing with a digital app can't be denied. Moreover, this app also comes with an import/export functionality, because knowledge is worth nothing if it can't be shared. The php file is available as a .tar.gz file here. As always, all you have to do is copy it somewhere in a php server like Apache, set the obfuscation parameter (cf below) and you're good to go.

Template for a single php file web app

All web apps of this series are based on the same model. Its first instance dates from 2023. It has improved little by little and I think now is a good timing to reintroduce it. If you want the boilerplate to make your own app then the current version is available here. The details about the app itself follows the introduction of the template.

The key points of this model are: one single php file with no dependencies or any assets, and an installation procedure as simple as copying a file and setting an obfuscation parameter. I also try as much as possible to keep these apps under 1k lines of code (a good part of it being the same template reused again and again), with a first working version generally written over a weekend. I take them as proof that there is really no need for all those bloated frameworks or AI for a human to create bare hand and in no time useful tools he/she entirely owns (both the data and the code).

The php file is divided into three sections: the php code, the html/css code, and the javascript code. The php section is as follow.

First of all I start the php session:

Then I make sure the error reporting is turned off to ensure it won't mess up with API calls (normally they will be catched, it's just an additional layer of safety net).

Next I make sure https is used to ensure the communication between the user and server will be encrypted. If the user tries to use http only the call is automatically redirected to https, except if the app is running on localhost, in which case it doesn't matter and simplify my life when I'm developping the app.

Now comes three global variables. The first one is used to obfuscate the filename of the SQlite database created by the app to save all the data. It should be set to a long password-like string of random alphanumerical characters. It avoids someone to access the database without using the app. The second one is the login for the guest account. If set, it allows to use a limited version of the app (what's "limited" depends on the app). And the third one is the app version. All the XXX and YYY are uppercase and lowercase versions of your app name.

Then comes the definition of the class implementing the app. The php section ends with the creation of an instance of this class, and based on the existence of the action parameter in the POST data I route between the methods API(), which process an API call, and Main(), which creates the DOM.

The class definition is as follow. First comes the class properties and their getters.

The constructor initialises the properties and the database connection. In the template the database definition contains only the table for the users. You can add the definition of other tables needed for your app in dbDef and they will be created automatically. The automatic creation of the database is done by trying first to open it with SQLITE3_OPEN_READWRITE. If it fails it means the database doesn't exist, then we can create it by using SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE and CreateDb() takes care of creating the tables as defined in dbDef.

The automatic database creation is done as follow. Loop on the database definition, create and execute the SQL command for each table creation. IF NOT EXISTS is used to be able to call that method more than once and create only non existing tables. It isn't useful in the template as it is now, and only left in preparation for future improvement of this model where an existing database would be automatically updated after dbDef is modified.

The destructor simply closes the connection to the database.

The Main() method checks if the user is logged in and set opMode accordingly. In the html section the DOM will be created based on this property: either a login interface, either the app interface.

The CheckLogin() method checks if the user is logged in, either because it has already logged in in the current session, or because it is providing credentials right now. If the guest account is set and it's the currently used account then the user is always considered logged in. If the user is providing credentials for login, they are checked against those saved in the database, or if there are no credentials yet then they are used to create the first (and single) user. The model currently allows for only one user (plus the eventual guest user). I plan to allow for several ones in the future once I find a solution that fits my taste. Upon login, a token is generated and used in API calls to ensure they come from a valid logged in user. Token also expires automatically after a given time.

The token verification is done as follow. For each API calls a login and token are provided by the user request. The token is checked against the one created for that user during login, and its expiration is checked at the same time. Here again, the guest user is considered to always have a valid token.

API calls are processed by the method API(). First of all the token is verified. Then the request is routed based on the action parameter. This is here where we eventually reject actions not allowed for a guest user. The result of a request is returned as a JSON encoded dictionary, which contains at least the actionResult entry, equal to ok if the request succeeded. The exit(0) at the end of the method ensures there is no other output that would mess up the JSON data.

The template also contains a dummy DoAction() method as a boilerplate with all the select/delete/insert/update SQL commands.

The html/css section is as follow.

The following css generally gives me 99% of all I need. I complete it as needed for each app.

The DOM of the app is contained in the divTop. According to the login status, a login interface or the app DOM is created. In both cases a header, message area and footer are also created. The header displays the app name. The message area displays various information to the user. The footer contains general information like the version, and links to logout and download the database if the user is logged in and not the guest. The DOM of the app can be created differently for the user or a guest by using <?php if($YYY->IsGuest() == false) :?>.

The javascript section is as follow.

First there are two utility functions. One to get a DOM element from its id (it's really just a shortcut). And the other to sleep the app (probably never used for something else than tests).

Like for the PHP section, everything is encapsulated in a class. An instance is created on window.onload and the necessary initialisation is performed. One can branch the code between guest user and logged in user by using the condition if(YYY.login != YYY.guestAccount).

The class constructor sets properties used to communicate with the API (the login and token), and may do some other app-specific initialisations. The condition if(this.login != "") allows to branch between the login interface and the app interface.

Two methods allow to send messages to the message area. One retains a given number of messages (nbMaxMessage property), deleting the oldest to add the newest when it's full. The other replaces the last message with a new one (useful to display progression messages). Messages are automatically dated by default, that may be useful, or not, depending on the app.

As most of the DOM is created dynamically I've made a convenient method to create new DOM element given its type and a dictionary of properties/values.

Finally, probably the most important method, used to communicate with the API. It takes in arguments the request parameters, two handlers called on success and failure of the API call, and extra parameters for the handlers. Non blocking by default, it can be called in blocking mode by using await.

A dummy method using Request() is also provided as a boilerplate:

And this is all for the template. Developping an actual app using it consists of implementing the API entry points in the php section to edit/retrieve necessary data, and in the javascript section implementing the DOM creation (based on these data) and handlers for its elements event (connecting them to the API and making the app alive). The details below about the Chishiki app give a concrete example.

The Chishiki app

The database definition is as follow. There is only one table: Entry. Each piece of knowledge in the app is called an entry. Keys is the list of keywords (space seperated) which identifies one entry (strictly speaking, the app doesn't forbid to create two entries with the same keys). Text stores the text of the entry. ImageHeader and Image store the entry's image data. The text and image are optional, but an entry must have at least one of them.

The API has 5 entry points, 3 of them unavailable for the guest user. They allow to search for entries based on keywords; get one entry's text and image; add a new entry; update an entry; get the reference of an entry.

Keys received from the user are sanitized with the following method to avoid leading, traliing and duplicated spaces.

Another method validates keys by checking that none of them starts with ":" which is reserved in Chishiki for special searches.

The entry point to add a new entry is as follow. First it checks for the validity of the keys, and the presence of at least one of the text and image. The image data, which are base64 encoded, are split into the header and body. This allows to decode the body and save it as a blob without loosing space due to the base64 encoded, and to keep the header separately in readable and easy to process format. The prepared data are finally saved in the database and the action result is returned.

There is no entry point for deletion of an entry, but it's covered by the one used to update an entry: if both the image and text are null, then we consider it is a deletion and reroute toward the following method, which simply delete the given reference.

The entry point for update of an entry is as follow. It is similar to the addition of a new entry, except for the routing toward the deletion if necessary, and UPDATE for a given reference instead of INSERT.

The entry point to get the reference of an entry is straightforward. The only noticeable thing here is that here we consider an exact match for the keys in argument (different from the search entry point).

The entry point to get an entry is as follow. To simplify the code in the javascript section it accepts invalid reference (≤0), for which it returns an empty entry. For a valid reference, it returns the entry data, after taking care of reassembling and reencoding in base64 the image body and header because that's the expected format on javascript side.

The last entry point is the one used to search entries matching given keys. There is currently one special keyword defined in Chishiki: ":all". It returns all the entries in the database. For normal keywords, I search entries whose keys included the requested keys. For examples, the entry's keys "a b c" will match "a" or "a b" or "a c" or "b a" etc... but will not match "d" or "aa" or "a d". The SELECT command is generated dynamically from each key in the keys in argument (sanitized and split on ' ').

The CSS styling has a few more definitions:

And the static DOM for the app is as follow:

Lastly, the javascript section for the Chishiki app is as follow. In the constructor, I initialise the flag to memorise if the current entry has been modified, and I set the current entry to a new one identified by the reference -1. I also add a new property which is the maximum size for the image of an entry.

The method to set the flag about modification enable/disable the 'save' and 'undo' buttons if they exist (the guest user has no access to edit functionalities).

The method to set the current entry updates the interface's buttons (save, cancel, delete), memorise the current entry reference. Then it displays the entry using the method DisplayEntry().

The method to display an entry first checks if the current one is modified. If it is then it request the user save it or undo the modifications. Else, it send a request to the API to get the entry's data. Here we don't mind if it's a new entry because the API is made to handle the case of a new entry. This simplify the code. The success handler updates the keys, text and image in the interface. One must take care of replacing the line return in the text into br elements to ensure they are correctly displayed in the textarea. For the image, thanks to the base64 encoding there is nothing else to do than set the data to the src property of the img element.

The method to save an entry is as follow. First it checks if the user has provided keys. If not, it replaces them with the search keys. Then it sanitizes these keys. It's also done on the API side, it's just another layer of protection. The action is set according to the current entry reference (add a new one, or update an existing one). The data are gathered from the interface and the request is sent. The display property of the image is used to identify if the entry has an image or not. If both the text and image were null then the entry is deleted and a new search with the current search keys is automatically performed to update the search results. The automatic search also occurs if a new entry has been saved, again to update the search results.

Undoing modifications is as simple as displaying again the current entry. This will reload the last saved data for the current entry and update the data in the interface.

The search functionality also starts by checking if the current entry is modified to avoid loosing modification. Then, it sends the request for a search with the current search keys to the API and displays the result using the DisplayResult() method. An argument allows to control if we want to display the first entry in the result (when the user is searching), or leave the current entry (when the user is editing an entry and the search purpose is only to refresh the results).

The default display of an entry consists of setting the current entry to the first one in the result if any, or to a new entry else.

The display of the results of a search is as follow. The result of the search are memorised for later use. The search result area in the interface is emptied, and its content is replaced with several buttons, one per entry in the result. Each button's label is the entry's keys, and is linked to SetCurrentEntry() with the reference of the corresponding entry. A reference to the chishiki instance is added to the button properties, which can then be retrieved with event.target.chishiki to call the method.

The deletion of an entry is a two steps operation for security: the "delete" button displays two "ok" and "cancel" buttons. The "cancel" button simply hides these two buttons and shows back the "delete" one. The "ok" button actually deletes the entry. It expoits the fact that an entry with no text and no image is deleted, then it deletes these data in the interface for the current entry and triggers a "save" request.

To set an image in the current entry, I use the FileReader and input type="file" element. The input allows the user to select the image, and the FileReader reads its content and returns it as base64 data, ready to be set in the img element of the current entry. The display property (none/inline-block) is used to known if the current entry has an image. Trying to set the image of an entry having one already is considered equivalent to removing the image (hence simply hiding it) to keep things simple.

The export function allows the user to share entries with someone else. Only the result entries of the current search are exported, giving some control on what you want to export. The data are exported as a text file in JSON format: [{"keys":"...","text":"...","image":"..."},...]. This also opens the door to interaction between chishiki and another application. The Export() method loops on the search result, requests each entry's data to the API, gathers them into a dictionary and save them on disk using an automatically triggered a element whose link is an ObjectURL created from a Blob of the JSON encoded dictionary. To avoid flooding the API with requests if the user tries to export many data at once, the request for entry's data are sent in blocking mode (await). In case this takes time, the user can follow the progress in the message area.

The importation of exported data (or data generated by another app) is done in two steps. First the imported data are read from a file, then the user select what to actually import from a list. Entries already existing in the database with exact same keys are overwritten, hence the necessity for the user to have the choice between "keep mine" or "update it". These conflicting entries are made easily identifiable thanks to visual clue.

Here again, to avoid flooding the API the import is done in blocking mode and the progress is displayed in the message area.

In the import tool, two buttons allow to set all entries at once to "import" or "ignore". This also is made with the view to simplify the usage when there are many entries.

Finally, all the event handler are set in the window.onload handler.

To end this article, a few screenshots of the app. The main interface after login:

The result of a search:

The import interface:

Example of an entry with an image:

Example in guest mode:

2026-07-16
in All, Web app,
9 views
A comment, question, correction ? A project we could work together on ? Email me!
Learn more about me in my profile.

This entire website was created without
any contribution from a generative AI.
ScienceIsPoetry
Copyright 2021-2026 Baillehache Pascal